From 2245367f54b2d6c52c3152f476342876a0e3c278 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:48:00 +0800 Subject: [PATCH 1/3] feat: integrate verified Windup source snapshot --- .github/assets/windup-mark.svg | 16 - README.md | 122 +- api-reference.md | 532 ++++ backend/check_provider.py | 31 + backend/init_db.py | 43 + backend/packages/ai_engine/pyproject.toml | 2 + .../src/windup_ai_engine/impl/__init__.py | 5 + .../impl/character_generator.py | 88 + .../src/windup_ai_engine/master_prep.py | 82 + .../src/windup_ai_engine/ports/__init__.py | 59 + .../windup_ai_engine/postprocess/__init__.py | 28 + .../src/windup_ai_engine/postprocess/pack.py | 95 + .../windup_ai_engine/postprocess/pixelate.py | 252 ++ .../postprocess/rootmotion.py | 69 + .../src/windup_ai_engine/prompt/__init__.py | 16 + .../src/windup_ai_engine/prompt/actions.py | 111 + .../src/windup_ai_engine/prompt/jump.py | 63 + .../src/windup_ai_engine/prompt/walk.py | 57 + .../src/windup_ai_engine/slicing/__init__.py | 27 + .../src/windup_ai_engine/slicing/extract.py | 162 ++ .../src/windup_ai_engine/slicing/loop.py | 46 + .../src/windup_ai_engine/slicing/oneshot.py | 189 ++ .../src/windup_ai_engine/strategy/__init__.py | 12 + .../src/windup_ai_engine/strategy/base.py | 51 + .../src/windup_ai_engine/strategy/concrete.py | 168 ++ .../app/src/windup_app/bootstrap/app.py | 84 +- .../src/windup_app/server/character/model.py | 10 +- .../windup_app/server/character/service.py | 69 + .../windup_app/server/generation/__init__.py | 6 + .../windup_app/server/generation/executor.py | 409 +++ .../windup_app/server/generation/interface.py | 25 +- .../src/windup_app/server/generation/model.py | 70 +- .../windup_app/server/generation/service.py | 76 + .../windup_app/server/generation/task_repo.py | 158 + .../src/windup_app/server/media/interface.py | 6 +- .../src/windup_app/server/media/service.py | 25 +- .../server/playtest_inspection/__init__.py | 1 + .../server/playtest_inspection/interface.py | 34 + .../server/playtest_inspection/model.py | 43 + .../server/playtest_inspection/service.py | 57 + .../windup_app/server/project/interface.py | 28 +- .../src/windup_app/server/project/model.py | 12 +- .../src/windup_app/server/project/service.py | 76 + .../app/src/windup_app/web/api/character.py | 173 ++ .../app/src/windup_app/web/api/generation.py | 126 +- .../windup_app/web/api/playtest_inspection.py | 89 + .../app/src/windup_app/web/api/project.py | 107 + .../src/windup_common/models/__init__.py | 15 + .../src/windup_common/models/character.py | 81 + backend/packages/framework/pyproject.toml | 15 +- .../src/windup_framework/config/__init__.py | 3 + .../src/windup_framework/config/database.py | 17 +- .../src/windup_framework/config/redis.py | 20 + .../src/windup_framework/config/storage.py | 16 +- .../src/windup_framework/db/__init__.py | 5 +- .../src/windup_framework/db/redis.py | 25 + .../src/windup_framework/db/session.py | 20 +- .../windup_framework/providers/__init__.py | 17 +- .../windup_framework/providers/interfaces.py | 34 + .../src/windup_framework/providers/matte.py | 103 + .../src/windup_framework/providers/sufy.py | 140 + backend/scripts/install-redis-wsl.sh | 28 + backend/scripts/start-redis-wsl.sh | 32 + backend/tests/conftest.py | 81 + backend/tests/test_ai_engine_skeleton.py | 121 + backend/tests/test_character_api.py | 53 + backend/tests/test_cors.py | 21 + backend/tests/test_generation_api.py | 273 ++ .../tests/test_generation_orchestration.py | 189 ++ backend/tests/test_loop.py | 46 + backend/tests/test_matte_provider.py | 16 + backend/tests/test_oneshot.py | 68 + backend/tests/test_pixelate.py | 103 + backend/tests/test_playtest_inspection_api.py | 71 + backend/tests/test_project_api.py | 174 ++ backend/tests/test_project_service.py | 185 ++ backend/tests/test_redis_runtime.py | 38 + backend/uv.lock | 2580 ++++++++++------- docs/module-split-plan.md | 110 + docs/module-split.md | 11 +- docs/sse-generation-flow.md | 242 ++ .../plans/2026-08-05-home-auth-account.md | 206 ++ .../2026-08-05-uploaded-template-shortcut.md | 157 + .../2026-08-05-home-auth-account-design.md | 86 + ...08-05-uploaded-template-shortcut-design.md | 58 + frontend-architecture-v3.md | 42 +- frontend/API_CONTRACT.md | 138 +- frontend/ARCHITECTURE_GUARDRAILS.md | 41 + frontend/MODULES.md | 31 + frontend/README.md | 14 +- frontend/package-lock.json | 49 + frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 2241 ++++++++++++++ .../src/app/__fixtures__/character-25.json | 114 + .../src/app/__fixtures__/character-list.json | 119 + .../src/app/__fixtures__/project-list.json | 289 ++ frontend/src/app/__fixtures__/task-70.json | 29 + frontend/src/app/__fixtures__/task-71.json | 109 + frontend/src/app/api-contract.test.ts | 94 + frontend/src/app/app-composition.test.tsx | 81 + frontend/src/app/app.test.tsx | 108 + frontend/src/app/app.tsx | 246 +- frontend/src/app/layout/app-header.test.tsx | 124 +- frontend/src/app/layout/app-header.tsx | 70 +- frontend/src/app/layout/index.test.tsx | 113 + frontend/src/app/layout/index.tsx | 48 +- frontend/src/architecture-boundaries.test.ts | 51 + .../src/entities/action-template/index.ts | 14 - frontend/src/entities/character/api.test.ts | 119 + frontend/src/entities/character/api.ts | 202 ++ frontend/src/entities/character/index.test.ts | 35 +- frontend/src/entities/character/index.ts | 325 +-- frontend/src/entities/generation/api.test.ts | 66 + frontend/src/entities/generation/api.ts | 210 ++ frontend/src/entities/generation/index.ts | 129 +- frontend/src/entities/index.ts | 56 +- frontend/src/entities/media/README.md | 37 + frontend/src/entities/media/api.test.ts | 190 ++ frontend/src/entities/media/api.ts | 81 + frontend/src/entities/media/index.ts | 14 + .../entities/playtest-inspection/api.test.ts | 67 + .../src/entities/playtest-inspection/api.ts | 65 + .../src/entities/playtest-inspection/index.ts | 24 + frontend/src/entities/project/api.test.ts | 57 + frontend/src/entities/project/api.ts | 105 + frontend/src/entities/project/index.test.ts | 2 +- frontend/src/entities/project/index.ts | 168 +- frontend/src/entities/user/api.test.ts | 199 ++ frontend/src/entities/user/api.ts | 138 + frontend/src/entities/user/index.ts | 35 + .../src/entities/workflow-run/constants.ts | 16 + frontend/src/entities/workflow-run/index.ts | 141 +- .../src/entities/workflow-run/store.test.ts | 403 +++ frontend/src/entities/workflow-run/store.ts | 493 ++++ .../src/features/auth-session/index.test.tsx | 348 +++ frontend/src/features/auth-session/index.tsx | 396 +++ .../auth-session/session-storage.test.ts | 49 + .../features/auth-session/session-storage.ts | 40 + .../features/character-setup/index.test.ts | 10 + .../src/features/character-setup/index.ts | 4 +- .../src/features/export-package/README.md | 47 + .../export-package/asset-export.test.ts | 269 ++ .../features/export-package/asset-export.ts | 534 ++++ .../features/export-package/cocos-target.ts | 15 + .../src/features/export-package/contract.ts | 113 + .../export-package/export-package.schema.json | 103 + .../export-package/export-panel.test.tsx | 121 + .../features/export-package/export-panel.tsx | 118 + frontend/src/features/export-package/index.ts | 29 + frontend/src/features/export-package/model.ts | 64 + frontend/src/features/publish/index.test.ts | 15 + frontend/src/features/publish/index.ts | 72 + .../features/publish/workflow-to-character.ts | 141 + frontend/src/features/review/index.ts | 36 +- .../action-generation-task.ts | 231 ++ .../character-template-task.ts | 464 +++ .../workflow-controller/controller.test.ts | 696 +++++ .../workflow-controller/controller.ts | 310 ++ .../src/features/workflow-controller/index.ts | 73 +- .../store-invariants.test.ts | 239 ++ .../workflow-run.integration.test.ts | 102 + .../workflow-state.test.ts | 256 ++ .../workflow-controller/workflow-state.ts | 584 ++++ frontend/src/index.css | 102 - .../src/pages/asset-library/index.test.tsx | 18 +- frontend/src/pages/asset-library/index.tsx | 44 +- .../src/pages/character-detail/index.test.tsx | 12 +- frontend/src/pages/character-detail/index.tsx | 30 +- frontend/src/pages/history/index.tsx | 112 + .../src/pages/home/account-panel.test.tsx | 390 +++ frontend/src/pages/home/account-panel.tsx | 570 ++++ frontend/src/pages/home/brand-bird.tsx | 154 - frontend/src/pages/home/choice-card.test.tsx | 23 + frontend/src/pages/home/choice-card.tsx | 172 +- frontend/src/pages/home/index.test.tsx | 159 +- frontend/src/pages/home/index.tsx | 209 +- frontend/src/pages/not-found/index.tsx | 12 +- frontend/src/pages/playtest/README.md | 49 + frontend/src/pages/playtest/assets.test.ts | 75 + frontend/src/pages/playtest/assets.ts | 50 + frontend/src/pages/playtest/demo-page.tsx | 61 + frontend/src/pages/playtest/entry.test.tsx | 136 + frontend/src/pages/playtest/entry.tsx | 81 + frontend/src/pages/playtest/index.test.tsx | 286 ++ frontend/src/pages/playtest/index.tsx | 324 ++- .../playtest/playtest-boundaries.test.ts | 116 + .../playtest/testing/demo-character.test.ts | 50 + .../pages/playtest/testing/demo-character.ts | 161 + .../playtest/testing/fixtures/boy/SOURCE.md | 7 + .../playtest/testing/fixtures/boy/base.png | Bin 0 -> 12196 bytes .../playtest/testing/fixtures/boy/idle-01.png | Bin 0 -> 12196 bytes .../playtest/testing/fixtures/boy/idle-02.png | Bin 0 -> 12069 bytes .../playtest/testing/fixtures/boy/idle-03.png | Bin 0 -> 11795 bytes .../playtest/testing/fixtures/boy/idle-04.png | Bin 0 -> 11910 bytes .../playtest/testing/fixtures/boy/idle-05.png | Bin 0 -> 12078 bytes .../playtest/testing/fixtures/boy/idle-06.png | Bin 0 -> 12478 bytes .../playtest/testing/fixtures/boy/idle-07.png | Bin 0 -> 12673 bytes .../playtest/testing/fixtures/boy/idle-08.png | Bin 0 -> 12156 bytes .../playtest/testing/fixtures/boy/walk-01.png | Bin 0 -> 12623 bytes .../playtest/testing/fixtures/boy/walk-02.png | Bin 0 -> 11763 bytes .../playtest/testing/fixtures/boy/walk-03.png | Bin 0 -> 11294 bytes .../playtest/testing/fixtures/boy/walk-04.png | Bin 0 -> 10956 bytes .../playtest/testing/fixtures/boy/walk-05.png | Bin 0 -> 11112 bytes .../playtest/testing/fixtures/boy/walk-06.png | Bin 0 -> 11508 bytes .../playtest/testing/fixtures/boy/walk-07.png | Bin 0 -> 11788 bytes .../playtest/testing/fixtures/boy/walk-08.png | Bin 0 -> 12282 bytes .../pages/playtest/workbench/acceptance.tsx | 82 + .../playtest/workbench/action-selector.tsx | 473 +++ .../workbench/analysis/frame-geometry.test.ts | 135 + .../workbench/analysis/frame-geometry.ts | 207 ++ .../workbench/analysis/image-geometry.test.ts | 151 + .../workbench/analysis/image-geometry.ts | 76 + .../workbench/analysis/quality-policy.ts | 123 + .../analysis/sequence-evidence.test.ts | 473 +++ .../workbench/analysis/sequence-evidence.ts | 603 ++++ .../use-frame-review-evidence.test.tsx | 203 ++ .../analysis/use-frame-review-evidence.ts | 106 + .../analysis/use-preview-quality-evidence.ts | 86 + .../analysis/visual-similarity.test.ts | 65 + .../workbench/analysis/visual-similarity.ts | 189 ++ .../workbench/animation-stage.test.tsx | 184 ++ .../playtest/workbench/animation-stage.tsx | 190 ++ .../workbench/audit/audit-panel.test.tsx | 106 + .../playtest/workbench/audit/audit-panel.tsx | 189 ++ .../workbench/audit/audit-session.test.ts | 53 + .../playtest/workbench/audit/audit-session.ts | 41 + .../workbench/frame-review-evidence.test.tsx | 257 ++ .../workbench/frame-review-evidence.tsx | 286 ++ .../playtest/workbench/frame-timeline.tsx | 58 + .../pages/playtest/workbench/index.test.tsx | 281 ++ .../src/pages/playtest/workbench/index.tsx | 529 ++++ .../pages/playtest/workbench/inspector.tsx | 64 + .../model/create-preview-model.test.ts | 126 + .../workbench/model/create-preview-model.ts | 101 + .../pages/playtest/workbench/model/types.ts | 48 + .../playtest/workbench/playback-controls.tsx | 94 + .../workbench/playback/playback-state.test.ts | 342 +++ .../workbench/playback/playback-state.ts | 155 + .../playback/use-playback-controller.test.tsx | 300 ++ .../playback/use-playback-controller.ts | 155 + .../playtest/workbench/stage-motion.test.tsx | 250 ++ .../pages/playtest/workbench/stage-motion.ts | 136 + .../pages/playtest/workbench/status-panel.tsx | 26 + .../workbench/use-playtest-inspection.ts | 89 + .../workbench/use-playtest-keyboard.test.tsx | 265 ++ .../workbench/use-playtest-keyboard.ts | 91 + .../src/pages/project-detail/index.test.tsx | 34 +- frontend/src/pages/project-detail/index.tsx | 50 +- frontend/src/pages/projects/create-page.tsx | 101 + frontend/src/pages/projects/index.test.tsx | 24 +- frontend/src/pages/projects/index.tsx | 15 +- frontend/src/pages/quick-start/index.test.tsx | 439 +++ frontend/src/pages/quick-start/index.tsx | 842 +++++- .../src/pages/quick-start/service.test.ts | 491 ++++ frontend/src/pages/quick-start/service.ts | 534 ++++ .../src/pages/workflow-editor/index.test.tsx | 347 +++ frontend/src/pages/workflow-editor/index.tsx | 451 ++- .../src/pages/workflow-editor/node-canvas.ts | 278 ++ frontend/src/pages/workflow-editor/service.ts | 179 ++ .../workflow-editor/workflow-canvas.test.tsx | 120 + .../pages/workflow-editor/workflow-canvas.tsx | 350 +++ .../pages/workflow-editor/workflow-editor.css | 1209 ++++++++ frontend/src/shared/api/http-client.test.ts | 124 + frontend/src/shared/api/http-client.ts | 5 + frontend/src/shared/api/index.ts | 48 +- frontend/src/shared/api/stream.test.ts | 108 + frontend/src/shared/api/stream.ts | 89 + frontend/src/shared/api/upload.ts | 114 + .../src/test/authenticated-app-routes.tsx | 35 + start.bat | 125 + start.command | 151 + 271 files changed, 36932 insertions(+), 2616 deletions(-) delete mode 100644 .github/assets/windup-mark.svg create mode 100644 api-reference.md create mode 100644 backend/check_provider.py create mode 100644 backend/init_db.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/master_prep.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py create mode 100644 backend/packages/app/src/windup_app/server/character/service.py create mode 100644 backend/packages/app/src/windup_app/server/generation/executor.py create mode 100644 backend/packages/app/src/windup_app/server/generation/service.py create mode 100644 backend/packages/app/src/windup_app/server/generation/task_repo.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/interface.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/model.py create mode 100644 backend/packages/app/src/windup_app/server/playtest_inspection/service.py create mode 100644 backend/packages/app/src/windup_app/server/project/service.py create mode 100644 backend/packages/app/src/windup_app/web/api/character.py create mode 100644 backend/packages/app/src/windup_app/web/api/playtest_inspection.py create mode 100644 backend/packages/app/src/windup_app/web/api/project.py create mode 100644 backend/packages/common/src/windup_common/models/__init__.py create mode 100644 backend/packages/common/src/windup_common/models/character.py create mode 100644 backend/packages/framework/src/windup_framework/config/redis.py create mode 100644 backend/packages/framework/src/windup_framework/db/redis.py create mode 100644 backend/packages/framework/src/windup_framework/providers/interfaces.py create mode 100644 backend/packages/framework/src/windup_framework/providers/matte.py create mode 100644 backend/packages/framework/src/windup_framework/providers/sufy.py create mode 100644 backend/scripts/install-redis-wsl.sh create mode 100644 backend/scripts/start-redis-wsl.sh create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_ai_engine_skeleton.py create mode 100644 backend/tests/test_character_api.py create mode 100644 backend/tests/test_cors.py create mode 100644 backend/tests/test_generation_api.py create mode 100644 backend/tests/test_generation_orchestration.py create mode 100644 backend/tests/test_loop.py create mode 100644 backend/tests/test_matte_provider.py create mode 100644 backend/tests/test_oneshot.py create mode 100644 backend/tests/test_pixelate.py create mode 100644 backend/tests/test_playtest_inspection_api.py create mode 100644 backend/tests/test_project_api.py create mode 100644 backend/tests/test_project_service.py create mode 100644 backend/tests/test_redis_runtime.py create mode 100644 docs/module-split-plan.md create mode 100644 docs/sse-generation-flow.md create mode 100644 docs/superpowers/plans/2026-08-05-home-auth-account.md create mode 100644 docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md create mode 100644 docs/superpowers/specs/2026-08-05-home-auth-account-design.md create mode 100644 docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md create mode 100644 frontend/ARCHITECTURE_GUARDRAILS.md create mode 100644 frontend/MODULES.md create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/app/__fixtures__/character-25.json create mode 100644 frontend/src/app/__fixtures__/character-list.json create mode 100644 frontend/src/app/__fixtures__/project-list.json create mode 100644 frontend/src/app/__fixtures__/task-70.json create mode 100644 frontend/src/app/__fixtures__/task-71.json create mode 100644 frontend/src/app/api-contract.test.ts create mode 100644 frontend/src/app/app-composition.test.tsx create mode 100644 frontend/src/app/app.test.tsx create mode 100644 frontend/src/app/layout/index.test.tsx create mode 100644 frontend/src/architecture-boundaries.test.ts delete mode 100644 frontend/src/entities/action-template/index.ts create mode 100644 frontend/src/entities/character/api.test.ts create mode 100644 frontend/src/entities/character/api.ts create mode 100644 frontend/src/entities/generation/api.test.ts create mode 100644 frontend/src/entities/generation/api.ts create mode 100644 frontend/src/entities/media/README.md create mode 100644 frontend/src/entities/media/api.test.ts create mode 100644 frontend/src/entities/media/api.ts create mode 100644 frontend/src/entities/playtest-inspection/api.test.ts create mode 100644 frontend/src/entities/playtest-inspection/api.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/entities/project/api.test.ts create mode 100644 frontend/src/entities/project/api.ts create mode 100644 frontend/src/entities/user/api.test.ts create mode 100644 frontend/src/entities/user/api.ts create mode 100644 frontend/src/entities/user/index.ts create mode 100644 frontend/src/entities/workflow-run/constants.ts create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/entities/workflow-run/store.ts create mode 100644 frontend/src/features/auth-session/index.test.tsx create mode 100644 frontend/src/features/auth-session/index.tsx create mode 100644 frontend/src/features/auth-session/session-storage.test.ts create mode 100644 frontend/src/features/auth-session/session-storage.ts create mode 100644 frontend/src/features/character-setup/index.test.ts create mode 100644 frontend/src/features/export-package/README.md create mode 100644 frontend/src/features/export-package/asset-export.test.ts create mode 100644 frontend/src/features/export-package/asset-export.ts create mode 100644 frontend/src/features/export-package/cocos-target.ts create mode 100644 frontend/src/features/export-package/contract.ts create mode 100644 frontend/src/features/export-package/export-package.schema.json create mode 100644 frontend/src/features/export-package/export-panel.test.tsx create mode 100644 frontend/src/features/export-package/export-panel.tsx create mode 100644 frontend/src/features/export-package/index.ts create mode 100644 frontend/src/features/export-package/model.ts create mode 100644 frontend/src/features/publish/index.test.ts create mode 100644 frontend/src/features/publish/index.ts create mode 100644 frontend/src/features/publish/workflow-to-character.ts create mode 100644 frontend/src/features/workflow-controller/action-generation-task.ts create mode 100644 frontend/src/features/workflow-controller/character-template-task.ts create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.ts create mode 100644 frontend/src/features/workflow-controller/store-invariants.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-run.integration.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.ts create mode 100644 frontend/src/pages/history/index.tsx create mode 100644 frontend/src/pages/home/account-panel.test.tsx create mode 100644 frontend/src/pages/home/account-panel.tsx delete mode 100644 frontend/src/pages/home/brand-bird.tsx create mode 100644 frontend/src/pages/playtest/README.md create mode 100644 frontend/src/pages/playtest/assets.test.ts create mode 100644 frontend/src/pages/playtest/assets.ts create mode 100644 frontend/src/pages/playtest/demo-page.tsx create mode 100644 frontend/src/pages/playtest/entry.test.tsx create mode 100644 frontend/src/pages/playtest/entry.tsx create mode 100644 frontend/src/pages/playtest/index.test.tsx create mode 100644 frontend/src/pages/playtest/playtest-boundaries.test.ts create mode 100644 frontend/src/pages/playtest/testing/demo-character.test.ts create mode 100644 frontend/src/pages/playtest/testing/demo-character.ts create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/base.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-03.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-04.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-06.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-07.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-06.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-07.png create mode 100644 frontend/src/pages/playtest/testing/fixtures/boy/walk-08.png create mode 100644 frontend/src/pages/playtest/workbench/acceptance.tsx create mode 100644 frontend/src/pages/playtest/workbench/action-selector.tsx create mode 100644 frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/image-geometry.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/quality-policy.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-preview-quality-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/visual-similarity.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/visual-similarity.ts create mode 100644 frontend/src/pages/playtest/workbench/animation-stage.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/animation-stage.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-panel.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-session.test.ts create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-session.ts create mode 100644 frontend/src/pages/playtest/workbench/frame-review-evidence.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-review-evidence.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-timeline.tsx create mode 100644 frontend/src/pages/playtest/workbench/index.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/index.tsx create mode 100644 frontend/src/pages/playtest/workbench/inspector.tsx create mode 100644 frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts create mode 100644 frontend/src/pages/playtest/workbench/model/create-preview-model.ts create mode 100644 frontend/src/pages/playtest/workbench/model/types.ts create mode 100644 frontend/src/pages/playtest/workbench/playback-controls.tsx create mode 100644 frontend/src/pages/playtest/workbench/playback/playback-state.test.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/playback-state.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/use-playback-controller.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/playback/use-playback-controller.ts create mode 100644 frontend/src/pages/playtest/workbench/stage-motion.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/stage-motion.ts create mode 100644 frontend/src/pages/playtest/workbench/status-panel.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-inspection.ts create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts create mode 100644 frontend/src/pages/projects/create-page.tsx create mode 100644 frontend/src/pages/quick-start/index.test.tsx create mode 100644 frontend/src/pages/quick-start/service.test.ts create mode 100644 frontend/src/pages/quick-start/service.ts create mode 100644 frontend/src/pages/workflow-editor/index.test.tsx create mode 100644 frontend/src/pages/workflow-editor/node-canvas.ts create mode 100644 frontend/src/pages/workflow-editor/service.ts create mode 100644 frontend/src/pages/workflow-editor/workflow-canvas.test.tsx create mode 100644 frontend/src/pages/workflow-editor/workflow-canvas.tsx create mode 100644 frontend/src/pages/workflow-editor/workflow-editor.css create mode 100644 frontend/src/shared/api/http-client.test.ts create mode 100644 frontend/src/shared/api/http-client.ts create mode 100644 frontend/src/shared/api/stream.test.ts create mode 100644 frontend/src/shared/api/stream.ts create mode 100644 frontend/src/shared/api/upload.ts create mode 100644 frontend/src/test/authenticated-app-routes.tsx create mode 100644 start.bat create mode 100644 start.command diff --git a/.github/assets/windup-mark.svg b/.github/assets/windup-mark.svg deleted file mode 100644 index 5b16f254..00000000 --- a/.github/assets/windup-mark.svg +++ /dev/null @@ -1,16 +0,0 @@ - - Windup - 侧视机械小鸟标志 - - - - - - - - - - - - - diff --git a/README.md b/README.md index 947814de..58c325b6 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,2 @@ -

- Windup 机械小鸟标志 -

- -

Windup

- -

- 面向国产小游戏开发者的 2D 角色动态素材生成与资产工作台 -

- -

交付的是资产,而不是图片。

- -Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧质检、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。 - -## 产品链路 / Product Workflow - -```text -新角色:文字描述 / 参考图 → 项目约束 → 角色母版 -已有角色:从资产库继续生产 ─────────────┘ - ↓ - 动作序列帧 → 逐帧审核 / 局部重生成 - ↓ - Playtest 试玩 → PNG / Sprite Sheet / 元数据 → 游戏引擎 -``` - -Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体帧或节点,已通过的结果继续保留。 - -## 核心对象 / Core Concepts - -| 对象 | 职责 | -| --- | --- | -| `Project` | 统一管理题材、美术风格、视角与精灵尺寸等项目级约束 | -| `Character` | 角色资产本体;造型、动作实例与帧属于它的资产树 | -| `ActionTemplate` | 可在不同角色间复用的动作规格与生产配方 | -| `Generation` | 一次生成任务及其输入、状态和结果,用于恢复与追溯 | -| `WorkflowRun` | 一次前端制作流程的运行记录,连接生成、确认、回退与导出 | - -产品提供两种入口:`Quick Start` 用自然语言建立标准生产流程;`Workflow Editor` 在系统预置的成熟管线上追加动作分支、微调参数和局部返工。两者共用同一套流程状态和质量门禁,分别服务快速创建与精细控制。 - -## 当前阶段 / Project Status - -MS2 已完成 Windup 的产品 MVP,验证了角色资产生产的核心链路。MS3 的重点从“完成一次生成”转向“持续完善已有角色资产”:用户可以从资产库回到已有角色,为它补充动作、重做有问题的分支,并保留未受影响的资产。 - -| 状态 | 内容 | -| --- | --- | -| MS2 产出 | 完成产品 MVP,跑通并验证角色资产生产的核心体验 | -| MS3 产品主线 | 已有角色补动作;工作流采用固定成熟管线,通过卡片加号追加分支,支持参数微调与局部重跑 | -| MS3 工程重点 | 持久化 `WorkflowRun` 并关联角色,串起工作流编辑、产物审核、节点回退与 Playtest | -| 后续探索 | Quick Start Agent、3D 动作生成路线、多视角资产与项目级导出 | - -项目进度见 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues)。 - -## 技术栈 / Tech Stack - -- 前端:React 19、TypeScript 6、Vite 8、Tailwind CSS 4、Vitest -- 后端:Python 3.12、FastAPI、Pydantic、SQLAlchemy、uv workspace -- 工程约束:GitHub Actions、Ruff、Pytest、Import Linter、oxlint、oxfmt - -## 本地开发 / Local Development - -前端支持 Node.js `^20.19.0`、`^22.12.0` 或 `>=24.0.0`;CI 使用 Node.js 24: - -```bash -cd frontend -npm ci -npm run dev -``` - -后端使用 Python 3.12 和 [uv](https://docs.astral.sh/uv/): - -```bash -cd backend -uv sync --frozen -uv run uvicorn windup_app.bootstrap.app:create_app --factory --reload -``` - -## 质量检查 / Quality Checks - -```bash -# frontend/ -npm run format:check -npm run lint -npm run typecheck -npm run test -npm run build - -# backend/ -uv run ruff check . -uv run lint-imports -uv run pytest -q -``` - -## 仓库结构 / Repository Structure - -```text -Windup/ -├── frontend/ # React 前端、页面与制作流程 -├── backend/ # Python 工作区、领域服务与 API -├── docs/ # 后端模块划分等工程文档 -├── frontend-architecture-v3.md -└── README.md -``` - -## 相关文档 / Documentation - -- [Windup 产品策划案](https://github.com/1024XEngineer/Windup/issues/37) -- [核心流程与工作流](https://github.com/1024XEngineer/Windup/issues/25) -- [前端架构与模块边界](frontend-architecture-v3.md) -- [前后端 API 契约差异](frontend/API_CONTRACT.md) -- [后端模块划分](docs/module-split.md) - -## 参与贡献 / Contributing - -问题、需求和实验记录统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。 - -项目的维护与历史贡献见 [Contributors](https://github.com/1024XEngineer/Windup/graphs/contributors)。 - -## 许可证 / License - -[Apache License 2.0](LICENSE) +# game-asset-character +Generate high-quality 2D game characters. diff --git a/api-reference.md b/api-reference.md new file mode 100644 index 00000000..ea910d80 --- /dev/null +++ b/api-reference.md @@ -0,0 +1,532 @@ +# Windup API 接口文档 + +> **Base URL**: `http://127.0.0.1:8000` +> **Content-Type**: `application/json`(除文件上传外) +> **最后更新**: 2026-07-30 + +--- + +## 目录 + +1. [项目管理 (Projects)](#1-项目管理-projects) +2. [角色管理 (Characters)](#2-角色管理-characters) +3. [媒体上传 (Media)](#3-媒体上传-media) +4. [生成任务 (Generation)](#4-生成任务-generation) +5. [通用说明](#5-通用说明) + +--- + +## 1. 项目管理 (Projects) + +### 1.1 创建项目 + +**`POST /projects`** + +| 参数 | 类型 | 必填 | 校验 | 说明 | +|---|---|-|---|-------------------------| +| `user_id` | int | ✅ | `>0` | 用户 ID | +| `project_name` | string | ✅ | `1~20字符` | 项目名称(同用户下不可重复) | +| `character_perspective` | int | ✅ | `1~3` | 角色视角(1=侧视, 2=正面, 3=正面) | +| `directional_movement` | int | ✅ | `1~3` | 方向移动方式 (1=单向,2=四向,3=八向) | +| `sprite_width` | int | ✅ | `32~2048` | 精灵图宽度 | +| `sprite_height` | int | ✅ | `32~2048` | 精灵图高度 | +| `workflow_id` | int \| null | | — | 工作流 ID | +| `game_style` | string \| null | | — | 游戏风格 | +| `sprite_sample_url` | string \| null | — | 精灵图示例 URL | + +**返回示例**: + +```json +{ + "code": 200, + "message": "创建成功", + "data": { + "id": 6, + "user_id": 1, + "project_name": "像素勇者", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + "workflow_id": null, + "game_style": null, + "sprite_sample_url": null, + "create_at": "2026-07-30T10:00:00Z", + "update_at": "2026-07-30T10:00:00Z" + } +} +``` + +**错误**:项目名重复返回 `400`。 + +--- + +### 1.2 项目列表 + +**`GET /projects`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|-|---|---| +| `user_id` | int \| null | null | 按用户筛选 | +| `page` | int | 1 | 页码(≥1) | +| `page_size` | int | 20 | 每页条数(1~100) | + +**返回示例**: + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 6, + "user_id": 1, + "project_name": "像素勇者", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + "create_at": "2026-07-30T10:00:00Z", + "update_at": "2026-07-30T10:00:00Z" + } + ], + "total": 1, + "page": 1, + "page_size": 20 +} +``` + +--- + +### 1.3 获取项目详情 + +**`GET /projects/{project_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `project_id` | int | path | 项目 ID | + +**返回**:单个 `ProjectOut` 对象(结构同列表项)。 + +--- + +### 1.4 删除项目 + +**`DELETE /projects/{project_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `project_id` | int | path | 项目 ID | + +**返回**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": null +} +``` + +--- + +## 2. 角色管理 (Characters) + +### 2.1 创建角色 + +**`POST /characters`** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `project_id` | int | ✅ | 所属项目 ID | +| `description` | string \| null | ❌ | 角色描述 | +| `reference_image_url` | string \| null | ❌ | 角色参考图 URL | +| `character_data` | object | ❌ | 角色完整数据(见下方结构) | + +**`character_data` 结构**: + +```json +{ + "version": 1, + "outfits": [ + { + "id": "outfit_01", + "name": "默认套装", + "description": "初始装备", + "preview_url": "http://...", + "actions": [ + { + "id": "walk_01", + "type": "walk", + "name": "走路", + "loop": true, + "fps": 12, + "frame_count": 8, + "frames": [ + { + "index": 0, + "image_url": "http://...", + "duration_ms": 125 + } + ] + } + ] + } + ] +} +``` + +**`character_data` 字段说明**: + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `version` | int | ❌ | 1 | 数据版本号 | +| `outfits` | list | ❌ | [] | 套装列表 | + +**`outfits[]` 字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `id` | string | ✅ | 套装唯一 ID | +| `name` | string | ✅ | 套装名称 | +| `description` | string \| null | ❌ | 套装描述 | +| `preview_url` | string \| null | ❌ | 套装预览图 URL | +| `actions` | list | ❌ | 动作列表 | + +**`outfits[].actions[]` 字段说明**: + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `id` | string | ✅ | — | 动作唯一 ID | +| `type` | string | ✅ | — | 动作类型:`walk` / `idle` / `attack` / `custom` | +| `name` | string | ✅ | — | 动作名称 | +| `loop` | bool | ❌ | false | 是否循环播放 | +| `fps` | float | ❌ | 12 | 帧率(>0) | +| `frame_count` | int | ❌ | 0 | 帧数(≥0) | +| `frames` | list | ❌ | [] | 帧列表 | + +**`actions[].frames[]` 字段说明**: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `index` | int | ✅ | 帧序号(从0开始) | +| `image_url` | string | ✅ | 帧图片 URL | +| `duration_ms` | int \| null | ❌ | 单帧时长(毫秒) | + +**返回示例**: + +```json +{ + "code": 200, + "message": "创建成功", + "data": { + "id": 1, + "project_id": 6, + "description": "武士角色", + "reference_image_url": "http://...", + "character_data": { "version": 1, "outfits": [] }, + "status": 1 + } +} +``` + +--- + +### 2.2 角色列表 + +**`GET /characters`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `project_id` | int | ✅ | — | 所属项目 ID | +| `page` | int | ❌ | 1 | 页码 | +| `page_size` | int | ❌ | 20 | 每页条数(1~100) | + +**返回**:`ListResponse[CharacterOut]`,结构同项目列表。 + +--- + +### 2.3 获取角色详情 + +**`GET /characters/{character_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `character_id` | int | path | 角色 ID | + +**返回**:单个 `CharacterOut` 对象。 + +--- + +### 2.4 更新角色 + +**`PATCH /characters/{character_id}`** + +> 只传需要修改的字段即可,未传的字段不修改。 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `description` | string \| null | ❌ | 角色描述 | +| `reference_image_url` | string \| null | ❌ | 参考图 URL | +| `character_data` | object \| null | ❌ | 完整角色数据(同创建) | + +**返回**:更新后的 `CharacterOut`。 + +--- + +### 2.5 删除角色 + +**`DELETE /characters/{character_id}`** + +| 参数 | 类型 | 位置 | 说明 | +|---|---|---|---| +| `character_id` | int | path | 角色 ID | + +**返回**: + +```json +{ + "code": 200, + "message": "删除成功", + "data": null +} +``` + +--- + +## 3. 媒体上传 (Media) + +### 3.1 上传图片 + +**`POST /media/upload`** + +> Content-Type: `multipart/form-data` + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `file` | File | ✅ | 图片文件(只接受 `image/*`) | +| `category` | string | ❌ | 分类:`reference-image` / `outfit-preview` / `action-frame` / `general`(默认 `general`) | + +**返回示例**: + +```json +{ + "code": 200, + "message": "上传成功", + "data": { + "url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/abc123.png", + "object_key": "media/reference-image/abc123.png", + "filename": "knight.png", + "content_type": "image/png", + "size": 16140 + } +} +``` + +**错误**:非图片文件返回 `400`。 + +--- + +## 4. 生成任务 (Generation) + +> 生成任务均为**异步**:先创建任务记录返回 `id`,前端轮询 `GET /generation/tasks/{id}` 获取状态和结果。 + +### 4.1 提交图片生成任务 + +**`POST /generation/image`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `user_id` | int | ✅ | — | 用户 ID | +| `project_id` | int \| null | ❌ | null | 项目 ID | +| `reference_image_url` | string \| null | ❌ | null | 参考图 URL(可选,纯文生图可不传) | +| `prompt` | string | ❌ | "" | 生成提示词 | +| `negative_prompt` | string | ❌ | "" | 反向提示词 | +| `width` | int | ❌ | 1024 | 输出宽度 | +| `height` | int | ❌ | 1024 | 输出高度 | +| `num_images` | int | ❌ | 1 | 生成数量 | + +**返回示例**: + +```json +{ + "code": 200, + "message": "任务已提交", + "data": { + "id": 9, + "user_id": 1, + "project_id": 6, + "task_type": "character_image", + "status": "pending", + "input_payload": { + "reference_image_url": null, + "prompt": "帮我生成一个穿着日本和服的女人", + "negative_prompt": "", + "width": 256, + "height": 256, + "num_images": 1 + }, + "result": {"image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/9516edb3261e45c39362e0a49e184fe1.png"}, + "error_message": null + } +} +``` + +--- + +### 4.2 提交动作生成任务 + +**`POST /generation/action`** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `user_id` | int | ✅ | — | 用户 ID | +| `project_id` | int \| null | ❌ | null | 项目 ID | +| `character_id` | int | ✅ | — | 角色 ID | +| `action_type` | string | ✅ | — | 动作类型:`walk` / `idle` / `attack` / `custom` | +| `custom_prompt` | string \| null | ❌ | null | 自定义提示词 | +| `reference_video_url` | string \| null | ❌ | null | 参考视频 URL | +| `reference_image_urls` | list[string] | ❌ | [] | 参考图 URL 列表(第一张作为母版) | +| `num_frames` | int | ❌ | 16 | 生成帧数 | + +**返回示例**: + +```json +{ + "code": 200, + "message": "任务已提交", + "data": { + "id": 15, + "user_id": 1, + "project_id": 6, + "task_type": "character_action", + "status": "pending", + "input_payload": { + "character_id": 1, + "action_type": "walk", + "custom_prompt": null, + "reference_image_urls": ["http://..."], + "num_frames": 8 + }, + "result": {"frames": [{"index": 0, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/35069fad379f4623be7e0bbdd389e6a9.png", "duration_ms": 125}, {"index": 1, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a25b81d26b7e42f49be05bbe2a2bf131.png", "duration_ms": 125}, {"index": 2, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/b39b7ae9d7a34bf1b022d38f6e149851.png", "duration_ms": 125}, {"index": 3, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9e5305bd74124f908459a45dbc7163b5.png", "duration_ms": 125}, {"index": 4, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/31331423ff974617a4f68c6e3dd93220.png", "duration_ms": 125}, {"index": 5, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/48e682d778dd4cc9b7b579f355a4896f.png", "duration_ms": 125}, {"index": 6, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cdbb141f59ed40e8816cd68a25a82d30.png", "duration_ms": 125}, {"index": 7, "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/f322162bd25847819a153edd0074c938.png", "duration_ms": 125}], "action_type": "walk"}, + "error_message": null + } +} +``` + +--- + +### 4.3 查询生成任务 + +**`GET /generation/tasks/{task_id}`** + +| 参数 | 类型 | 位置 | 必填 | 说明 | +|---|---|---|---|---| +| `task_id` | int | path | ✅ | 任务 ID | +| `project_id` | int | query | ✅ | 项目 ID | + +**状态流转**:`pending` → `running` → `completed` / `failed` + +**completed 时的 result 结构**: + +- **图片任务** (`character_image`): + +```json +{ + "result": { + "type": "character_image", + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/xxx.png" + } +} +``` + +- **动作任务** (`character_action`): + +```json +{ + "result": { + "type": "character_action", + "action_type": "walk", + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/xxx.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://...", + "duration_ms": 125 + } + ] + } +} +``` + +**failed 时**: + +```json +{ + "status": "failed", + "error_message": "具体错误信息", + "result": null +} +``` + +--- + +## 5. 通用说明 + +### 5.1 统一响应格式 + +**单条数据** `Response[T]`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | int | 业务状态码(200=成功) | +| `message` | string | 状态消息 | +| `data` | T \| null | 业务数据 | + +**列表数据** `ListResponse[T]`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | int | 业务状态码 | +| `message` | string | 状态消息 | +| `data` | list[T] | 数据列表 | +| `total` | int | 总条数 | +| `page` | int | 当前页 | +| `page_size` | int | 每页条数 | + +### 5.2 错误码 + +| HTTP 状态码 | 说明 | +|---|---| +| 200 | 成功 | +| 400 | 请求参数错误 | +| 404 | 资源不存在 | + +### 5.3 枚举值 + +**动作类型 `action_type`**:`walk` / `idle` / `attack` / `custom` + +**媒体分类 `category`**:`reference-image` / `outfit-preview` / `action-frame` / `general` + +**任务状态 `status`**:`pending` → `running` → `completed` / `failed` + +### 5.4 生成任务轮询建议 + +```javascript +// 前端轮询示例 +async function pollTask(taskId, projectId) { + while (true) { + const res = await fetch(`/generation/tasks/${taskId}?project_id=${projectId}`); + const { data } = await res.json(); + + if (data.status === 'completed') return data.result; + if (data.status === 'failed') throw new Error(data.error_message); + + await new Promise(r => setTimeout(r, 2000)); // 2秒轮询 + } +} +``` diff --git a/backend/check_provider.py b/backend/check_provider.py new file mode 100644 index 00000000..143133fe --- /dev/null +++ b/backend/check_provider.py @@ -0,0 +1,31 @@ +"""Verify AI Provider config & connectivity to modelink API.""" +import httpx +from windup_framework.config.provider import AIProviderSettings + +s = AIProviderSettings() +print("=== AI Provider Config ===") +print(f" Base URL: {s.normalized_base_url}") +print(f" API Key: {s.api_key[:16]}...{s.api_key[-8:]}") +print(f" Model: {s.model or '(not set - using per-provider defaults)'}") +print(f" Timeout: {s.timeout}s") +print(f" Max Retries: {s.max_retries}") +print(f" Provider: {s.provider}") +print() + +# Quick connectivity test +try: + resp = httpx.get( + f"{s.normalized_base_url}/models", + headers={"Authorization": f"Bearer {s.api_key}"}, + timeout=10, + ) + if resp.status_code == 200: + models = resp.json().get("data", []) + print(f"API Connectivity: OK ({len(models)} models available)") + print("Top models:") + for m in models[:8]: + print(f" - {m.get('id', '?')}") + else: + print(f"API returned: {resp.status_code} {resp.text[:200]}") +except Exception as e: + print(f"Connectivity test failed: {e}") diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 00000000..2978a9c4 --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""初始化数据库并启动后端服务。 + +使用 SQLite 作为开发数据库,避免依赖 PostgreSQL。 +""" + +import os + +# 设置 SQLite 路径 +os.environ["SQLITE_PATH"] = "./windup.db" + +# 导入数据库相关模块 +from windup_framework.db.base import Base +from windup_framework.db.session import engine + +# 导入所有模型,以便创建表 +from windup_app.server.character.model import Character # noqa: F401 +from windup_app.server.generation.model import GenerationTaskRecord # noqa: F401 +from windup_app.server.playtest_inspection.model import PlaytestInspection # noqa: F401 +from windup_app.server.project.model import Project # noqa: F401 + + +def init_database(): + """初始化数据库,创建所有表。""" + print("正在初始化数据库...") + Base.metadata.create_all(engine) + print("数据库初始化完成!") + + +def main(): + """主函数:初始化数据库并启动后端服务。""" + # 初始化数据库 + init_database() + + # 启动后端服务 + print("正在启动后端服务...") + from windup_app.bootstrap.app import main as start_server + + start_server() + + +if __name__ == "__main__": + main() diff --git a/backend/packages/ai_engine/pyproject.toml b/backend/packages/ai_engine/pyproject.toml index bb279425..84492ec8 100644 --- a/backend/packages/ai_engine/pyproject.toml +++ b/backend/packages/ai_engine/pyproject.toml @@ -10,6 +10,8 @@ dependencies = [ "langchain-core>=0.3", "pillow>=10.4", "numpy>=1.26", + "imageio>=2.36", + "av>=14.0", # imageio pyav 后端(视频抽帧) # "rembg", # 抠图(按需启用) ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py new file mode 100644 index 00000000..456b868a --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py @@ -0,0 +1,5 @@ +"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。""" + +from .character_generator import CharacterGenerator + +__all__ = ["CharacterGenerator"] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py new file mode 100644 index 00000000..ecdfd6cf --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -0,0 +1,88 @@ +"""CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。 + +这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。 +串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。 + +MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储—— +上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +from windup_common.models import ActionSpec, CharacterCard, GenRoute + +from windup_ai_engine.ports import ( + CharacterGeneratorPort, + GeneratedAction, + ProgressPort, +) +from windup_ai_engine.postprocess import align_bottom_center, frame_durations +from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def _img(png: bytes) -> Image.Image: + return Image.open(io.BytesIO(png)).convert("RGBA") + + +class CharacterGenerator(CharacterGeneratorPort): + """由 bootstrap 注入 {GenRoute: DerivationStrategy} 装配表。""" + + def __init__(self, strategies: dict[GenRoute, DerivationStrategy]) -> None: + self._by_route = strategies + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: + # ① 选路线(架构决策矩阵) + route = ROUTE_MATRIX[action.action] + progress.step("route", 0, 3, f"{action.action} → {route.value}") + strategy = self._by_route[route] + + # ② 生成帧(交给 strategy —— 串联) + frames = strategy.derive(card, action, master, progress) + + # ③ 最后一公里:脚线对齐成原地序列帧 + frames = self._lastmile(frames, progress) + + # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧) + progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长") + return GeneratedAction( + frames=frames, + durations=frame_durations(action.action.value, len(frames)), + fps=action.fps, + ) + + def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: + """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + + 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): + 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 + """ + progress.step("lastmile", 1, 3, "脚线对齐(原地)") + if not frames or not all(frames): # 含空桩帧(未开发路线)→ 跳过 + return frames + imgs = [_img(f) for f in frames] + # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏), + # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。 + import numpy as _np + _hs = [] + for _im in imgs: + _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128) + if len(_ys): + _hs.append(float(_ys.max() - _ys.min())) + aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None)) + # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) + return [_png(im) for im in aligned] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py new file mode 100644 index 00000000..ed2652e3 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -0,0 +1,82 @@ +"""母版规格与预处理:每个动作需要什么样的母版。 + +**核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。** + - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。 + - jump:母版**顶部留白**才不被视频画面裁掉。 + - attack:必须给**极限蓄力母版**(武器已拉到身后腰际)。用站立母版时,即使提示词写死 + "武器不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作 + 先验压不住;换蓄力母版后模型只能"接着往前挥",没有再抡起的空间。 + + +实测教训:母版里角色居中、占 ~70% 画面高时,i2v 跳跃会让角色**头顶顶出视频画面上沿** +被裁掉(生成本身没错,是构图没留够空间)。规则同 MasterSpec 的"运动方向多留白": + - jump:向上运动 → 顶部补空间,角色坐低 + - dash / walk / run:向右位移 → 前进方向多留白(由母版生成时构图保证,此处不改) + +纯 PIL,零 API。背景色取母版四角中位色,补出来的边与母版底色一致。 +""" + +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +__all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] + +# 各动作所需的母版姿态(生成专用母版时的姿势描述)。空=可直接用中性站立母版。 +MASTER_POSES = { + "walk": "", # 中性站立即可,但必须朝侧向 + "run": "", + "idle": "", + # jump:与 attack 同理——重甲带剑角色的"跳跃"强动作先验压不住(站立母版会让模型摆 + # 造型、只举剑不腾空,实测)。给**极限蓄力半蹲母版**,模型只能"接着往上蹬"。顶部留白 + # 由 prepare_master(add_headroom)保证。 + "jump": ( + "deep crouch coiled to spring straight upward: the knees bent low and the hips sunk down, " + "both arms drawn back behind the body, the weight loaded onto both legs at the very moment " + "before springing straight up, the weapon kept in a fixed grip; " + "leave generous empty space above the head" + ), + "attack": ( + "extreme wind-up stance for a horizontal slash: the weapon drawn far BACK behind the body " + "at WAIST height, the torso twisted back and coiled, weight fully loaded on the back leg, " + "both arms low and pulled back, the weapon staying BELOW the shoulders; " + "leave generous empty space on the swing side" + ), +} + + +def _bg_color(img: Image.Image) -> tuple[int, int, int]: + """取四角中位色当背景色(母版通常是纯色底)。""" + rgb = np.asarray(img.convert("RGB")) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + return tuple(int(v) for v in np.median(corners, axis=0)) + + +def add_headroom(master: bytes, ratio: float = 0.6) -> bytes: + """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。 + + Args: + master: 母版图 bytes。 + ratio: 处理后角色所占的画面高度比例(越小头顶空间越多)。0.6 表示角色高度 + 约占新画面的 60%,上方留约 40%。 + """ + if not 0.1 < ratio < 1.0: + raise ValueError("ratio 需在 (0.1, 1.0) 之间") + img = Image.open(io.BytesIO(master)).convert("RGB") + new_h = max(img.height + 1, int(round(img.height / ratio))) + canvas = Image.new("RGB", (img.width, new_h), _bg_color(img)) + canvas.paste(img, (0, new_h - img.height)) # 原图贴底,空间加在顶部 + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +def prepare_master(master: bytes, action: str) -> bytes: + """按动作类型预处理母版;不需要处理的动作原样返回。""" + if action in ("jump", "attack"): + # jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶) + return add_headroom(master, ratio=0.62 if action == "jump" else 0.70) + return master diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py new file mode 100644 index 00000000..efe91c64 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -0,0 +1,59 @@ +"""ai_engine 对外契约(ports)—— server 只 import 这里,不碰 slicing / strategy / impl。 + +CI 的 import-linter 分层门禁会强制:app.server 依赖只到 ai_engine.ports。 +换掉内部实现(strategy / provider)时 server 零改动。 + +MVP 边界(与作者对齐):ai_engine **只产出帧 bytes + 进度**,不碰存储 / DB。 +母版(master)由 server 侧从 ``Character.reference_image_url`` 取好、以 bytes 传入; +产出的帧由 server 侧上传对象存储、落 ``character_data``。故本层无 ArtifactStore 依赖。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from windup_common.models import ActionSpec, CharacterCard + + +# ---- server 实现、注入给 ai_engine 的进度回调 port ---- +class ProgressPort(Protocol): + """进度上报 —— server 转 SSE / 轮询状态(取代管线里的 print)。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: ... + + +# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)---- +@dataclass +class GeneratedAction: + """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长。 + + frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成 + ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。 + """ + + frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 + fps: int = 10 + + +# ---- ai_engine 暴露给 server(server 调用的唯一入口)---- +@runtime_checkable +class CharacterGeneratorPort(Protocol): + """生成入口:角色卡 + 动作规格 + 母版 → 帧序列产物。 + + 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。 + + Args: + card: 角色卡(身份 / 画风 / 朝向)。 + action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。 + master: 定妆母版图 bytes(server 从 reference_image_url 取)。 + progress: 进度回调。 + """ + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: ... diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py new file mode 100644 index 00000000..e69f0ec7 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py @@ -0,0 +1,28 @@ +"""后处理:把选好的帧落地成交付级序列帧(像素化 / 对齐 / 打包)。 + +抽帧 / 选帧见 :mod:`..slicing`。逐帧时长 ``frame_durations`` 在 :mod:`.rootmotion`。 +""" + +from .rootmotion import DEFAULT_FPS_MS, extract_root_motion, frame_durations +from .pixelate import ( + detect_pixel_size, + extract_palette, + master_pixel_spec, + pixelate_frames, + to_pixel_art, +) +from .pack import align_bottom_center, save_gif, sprite_sheet + +__all__ = [ + "to_pixel_art", + "pixelate_frames", + "detect_pixel_size", + "extract_palette", + "master_pixel_spec", + "extract_root_motion", + "frame_durations", + "DEFAULT_FPS_MS", + "align_bottom_center", + "sprite_sheet", + "save_gif", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py new file mode 100644 index 00000000..1babf2b7 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -0,0 +1,95 @@ +"""对齐 / 打包(后处理的收尾:脚线对齐 → sprite sheet / gif)。 + +抽帧 / 选帧见 :mod:`..slicing`,像素化见 :mod:`.pixelate`,抠图见 framework 的 +MatteProvider(#20)。本模块把对齐后的帧拼成交付物。 +""" + +from __future__ import annotations + +from PIL import Image + +__all__ = ["align_bottom_center", "sprite_sheet", "save_gif"] + + +def align_bottom_center( + frames: list[Image.Image], + cell: int = 256, + foot_line: float = 0.92, + fill_h: float = 0.62, + preserve_lift: bool = False, + ref_height: float | None = None, +) -> list[Image.Image]: + """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。 + + **整段共用一个缩放系数**(取全序列最高帧定标),不逐帧归一化 —— 逐帧各自缩放到等高 + 会把走路自然的身高起伏(实测约 4%)反向变成"忽大忽小":蹲下的帧被放大、伸展的帧被 + 缩小。统一缩放后帧间只剩真实姿态差,尺度稳定。 + + 水平方向按**主体水平中心**对齐(不含挥出的武器会更好,当前用整体包围盒中心兜底); + 垂直方向按**脚线**(包围盒底边)对齐到 ``foot_line``。 + + ``ref_height``:**跨动作一致性的关键**,单位=传入帧的像素高。给定时按它定标,否则按本 + 序列最高帧。按最高帧定标会让"举过头顶"的动作整段被缩小去迁就那一帧 —— 实测攻击时 + 斧头高举使 bbox 从 485 涨到 660,角色本体因此明显变小;跳跃顶点同理。故传入**参考姿态** + (站立)的高度,各动作即共用同一本体尺寸。``fill_h`` 默认 0.62,给举过头顶留出余量。 + + ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把 + 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。 + """ + import numpy as np + + boxes: list[tuple[int, int, int, int] | None] = [] + for f in frames: + ys, xs = np.where(np.asarray(f)[:, :, 3] > 128) + boxes.append( + (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + if len(ys) + else None + ) + heights = [b[3] - b[1] for b in boxes if b] + if not heights: + return [Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) for _ in frames] + # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量 + ground = max(b[3] for b in boxes if b) if preserve_lift else 0 + # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉 + if preserve_lift: + need = max((ground - b[3]) + (b[3] - b[1]) for b in boxes if b) + scale = (cell * fill_h) / max(1, need) + elif ref_height: + scale = (cell * fill_h) / ref_height # 参考姿态定标(跨动作一致) + else: + scale = (cell * fill_h) / max(heights) # 回退:本序列最高帧 + + out = [] + for f, box in zip(frames, boxes): + if box is None: + out.append(Image.new("RGBA", (cell, cell), (0, 0, 0, 0))) + continue + crop = f.crop(box) + w = max(1, round(crop.width * scale)) + h = max(1, round(crop.height * scale)) + crop = crop.resize((w, h), Image.NEAREST) + lift = round((ground - box[3]) * scale) if preserve_lift else 0 + canvas = Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) + canvas.alpha_composite(crop, (cell // 2 - w // 2, int(cell * foot_line) - h - lift)) + out.append(canvas) + return out + + +def sprite_sheet(frames: list[Image.Image], bg=(0, 0, 0, 0)) -> Image.Image: + """横向拼接为 sprite sheet。""" + if not frames: + raise ValueError("frames 为空") + w, h = frames[0].size + sheet = Image.new("RGBA", (w * len(frames), h), bg) + for i, f in enumerate(frames): + sheet.alpha_composite(f.convert("RGBA"), (i * w, 0)) + return sheet + + +def save_gif(frames: list[Image.Image], path: str, duration: int = 120) -> None: + """导出循环 gif 供预览。""" + if not frames: + raise ValueError("frames 为空") + rgba = [f.convert("RGBA") for f in frames] + rgba[0].save(path, save_all=True, append_images=rgba[1:], duration=duration, loop=0, disposal=2) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py new file mode 100644 index 00000000..4910fa96 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py @@ -0,0 +1,252 @@ +"""像素化后处理:把生成帧转成脆边限色的像素精灵。 + +视频路线实测(Issue #35): +- i2v 能解决步态(腿真交替、不转身);对**插画风**角色它保留插画质感 → 需要像素化转风格。 +- 对**原生像素**角色 i2v 其实能保住像素感,但链路上两道有损压缩(首帧 JPG q90 + 视频 H.264) + 会在硬边处产生振铃噪点(表现为灰颗粒),像素越细越明显;而通用的"降采样 + 32 色量化" + 因为**网格对不齐**反而更糊。 +- 解法:有母版时按 :func:`master_pixel_spec` 量出母版的**原生像素块大小**与**真实色板**, + 按母版网格降采样 + 颜色吸附回母版色板 —— 压缩灰颗粒不属于色板,会被强制消掉。 + +纯 Pillow / numpy,零 API、秒级,符合"本机只做轻量 CV"的算力约束。 +输入约定:RGBA 图(alpha 为主体掩码,抠图见 framework 的 MatteProvider / Issue #20)。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = [ + "to_pixel_art", + "pixelate_frames", + "detect_pixel_size", + "extract_palette", + "master_pixel_spec", +] + + +def _content_bbox(rgba: Image.Image, alpha_thr: int = 128) -> tuple[int, int, int, int]: + """求主体包围盒。 + + 用 :func:`_subject_mask` 而非只看 alpha:母版常是**不透明白底**,只看 alpha 会把整张 + 画布当主体,导致逻辑像素高被算成整图高而非角色高(实测踩过)。 + """ + mask = _subject_mask(rgba.convert("RGBA"), alpha_thr) + ys, xs = np.where(mask) + if len(ys): + return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 + return 0, 0, rgba.width, rgba.height + + +def _axis_block_size(crop: np.ndarray, axis: int, min_delta: int, min_frac: float) -> int: + """沿 ``axis`` 估块边长:显著色变位置 → 合并相邻 → 取最常见间距。""" + d = np.abs(np.diff(crop, axis=axis)).sum(axis=2) + frac = (d > min_delta).mean(axis=1 - axis) + edges = np.flatnonzero(frac > min_frac) + 1 + if len(edges) < 3: + return 1 + # 块边界常因轻微抗锯齿占相邻两行/列,合并成一条,否则 gap=1 会淹没真实值 + edges = edges[np.concatenate([[True], np.diff(edges) > 1])] + gaps = np.diff(edges) + gaps = gaps[gaps >= 2] + return int(np.bincount(gaps).argmax()) if len(gaps) else 1 + + +def detect_pixel_size( + img: Image.Image, min_delta: int = 30, min_frac: float = 0.02, max_size: int = 64 +) -> int: + """检测像素画的原生像素块边长(非像素画/检测不出时返回 1)。 + + 原理:像素画的色块边界落在同一网格上,相邻边界间距 = 块边长的整数倍,故取 + **最常见间距**即块边长。两轴分别估,取较小者(更保守,宁可细不可糊)。 + """ + rgba = img.convert("RGBA") + x0, y0, x1, y1 = _content_bbox(rgba) + crop = np.asarray(rgba.crop((x0, y0, x1, y1)).convert("RGB")).astype(np.int16) + if crop.size == 0: + return 1 + sizes = [_axis_block_size(crop, ax, min_delta, min_frac) for ax in (0, 1)] + best = min(s for s in sizes) if all(s >= 1 for s in sizes) else 1 + return max(1, min(best, max_size)) + + +def _erode(mask: np.ndarray, k: int) -> np.ndarray: + """二值腐蚀 k 次(纯 numpy 移位,不引 scipy)。""" + m = mask + for _ in range(max(0, k)): + m = ( + m + & np.roll(m, 1, 0) + & np.roll(m, -1, 0) + & np.roll(m, 1, 1) + & np.roll(m, -1, 1) + ) + if not m.any(): + return mask + return m + + +def _subject_mask( + rgba: Image.Image, alpha_thr: int = 128, bg_tol: int = 40, erode: int = 0 +) -> np.ndarray: + """主体掩码:优先用真实 alpha;母版常是**不透明白底**,此时按四角背景色排除背景。 + + 两个实测踩过的坑: + 1. 不排背景 → 白底占多数像素、吃光色板名额 → 角色被整体吸附成白色。 + 2. 排了背景但保留边缘 → 角色/白底之间的**抗锯齿过渡色**(近白)混进色板 → + 视频里的浅色噪点就近吸附成白点,满身白斑。故取色板时用 ``erode`` 腐蚀掉边缘。 + """ + arr = np.asarray(rgba) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: # 有真实抠图 + mask = alpha > alpha_thr + else: + rgb = arr[:, :, :3].astype(np.int16) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + bg = np.median(corners, axis=0) + mask = np.abs(rgb - bg).sum(axis=2) > bg_tol + return _erode(mask, erode) + + +def extract_palette( + img: Image.Image, max_colors: int = 32, alpha_thr: int = 128, erode: int = 3 +) -> np.ndarray: + """提取母版真实色板,返回 (K,3) uint8。 + + 只统计主体像素(见 :func:`_subject_mask`,并腐蚀掉抗锯齿边缘),再用中位切分量化 + 归并噪声色 —— 生成的"像素画"常带轻微噪点/抗锯齿,同一名义色被打散成大量近似色, + 直接按频率统计会全被当杂色滤掉。 + """ + rgba = img.convert("RGBA") + arr = np.asarray(rgba) + mask = _subject_mask(rgba, alpha_thr, erode=erode) + pixels = arr[:, :, :3][mask] + if not len(pixels): + pixels = arr[:, :, :3].reshape(-1, 3) + strip = Image.fromarray(pixels.reshape(1, -1, 3).astype(np.uint8), "RGB") + quant = strip.quantize(colors=max(2, max_colors), method=Image.MEDIANCUT) + pal = np.asarray(quant.getpalette()[: max(2, max_colors) * 3], dtype=np.uint8).reshape(-1, 3) + used = np.unique(np.asarray(quant)) + return pal[used[used < len(pal)]] + + +def master_pixel_spec(master: Image.Image, max_colors: int = 48) -> tuple[int, np.ndarray]: + """从母版量出 (角色的逻辑像素高, 母版色板)。 + + 逻辑像素高 = 母版里角色占的像素行数 ÷ 原生像素块边长 —— 即"这个角色本来是多少 + 像素高的精灵"。用它当 ``target_h`` 可自动吸附网格,不必人肉猜分辨率。 + + ``max_colors`` 实测取值:32 太少 —— 中位切分按面积分箱,大面积色(如裸腿肤色/棕靴) + 会挤占名额,小面积但需渐变的衣服色档位不足 → 中间调就近吸到邻近色相(绿衣泛橄榄黄); + 96 太多 —— 抗锯齿近白色重新拿到独立分箱 → 边缘冒白噪点。48 是实测的安全区。 + """ + x0, y0, x1, y1 = _content_bbox(master.convert("RGBA")) + block = detect_pixel_size(master) + logical_h = max(1, round((y1 - y0) / block)) + return logical_h, extract_palette(master, max_colors=max_colors) + + +def _to_perceptual(rgb: np.ndarray) -> np.ndarray: + """RGB → 近似感知空间(亮度 + 两个色差轴),float32。 + + 直接在 RGB 里取最近邻会**跳色相**:绿衣的中间调可能被吸到橄榄黄(实测踩过)。 + 换成亮度/色差轴并给色差加权后,同色相内的明暗过渡优先匹配,色相跳变被压住。 + 这里用 YCbCr 型线性变换(比 Lab 便宜得多,足够拉开色相)。 + """ + f = rgb.astype(np.float32) + r, g, b = f[..., 0], f[..., 1], f[..., 2] + y = 0.299 * r + 0.587 * g + 0.114 * b + cb = b - y + cr = r - y + w = 2.0 # 色差权重 >1:宁可亮度差一点,也别换色相 + return np.stack([y, w * cb, w * cr], axis=-1) + + +def _snap_to_palette(rgb: np.ndarray, palette: np.ndarray) -> np.ndarray: + """把每个像素吸附到色板中最近的颜色(感知空间最近邻,分块避免大内存)。 + + 用 float32 感知空间:①避免 int16 平方距离溢出(255² > 32767,实测让绿衣变肉色); + ②按色相优先匹配,防止 RGB 空间里的跨色相跳变。 + """ + flat = _to_perceptual(rgb).reshape(-1, 3) + pal_p = _to_perceptual(palette).reshape(-1, 3) + pal_rgb = palette.astype(np.uint8).reshape(-1, 3) + out = np.empty((len(flat), 3), dtype=np.uint8) + step = 65536 + for i in range(0, len(flat), step): + chunk = flat[i : i + step] + d = ((chunk[:, None, :] - pal_p[None, :, :]) ** 2).sum(axis=2) + out[i : i + step] = pal_rgb[d.argmin(axis=1)] + return out.reshape(rgb.shape) + + +def to_pixel_art( + rgba: Image.Image, + target_h: int = 100, + palette_size: int = 32, + alpha_thr: int = 128, + palette: np.ndarray | None = None, +) -> Image.Image: + """单帧转像素风,返回小尺寸 RGBA(``target_h`` 高,等比宽)。 + + 步骤:裁到主体包围盒 → 等比缩到 ``target_h``(NEAREST 网格降采样)→ 限色。 + 限色两种模式: + - ``palette`` 给定(推荐,原生像素角色):**吸附到母版真实色板**,顺带消掉 + JPG/H.264 在硬边留下的灰颗粒。 + - ``palette=None``(插画转像素):按 ``palette_size`` 做八叉树量化。 + + Args: + target_h: 目标像素高;原生像素角色建议用 :func:`master_pixel_spec` 算出的逻辑高。 + palette_size: 无母版色板时的量化色数。 + palette: (K,3) uint8 母版色板。 + """ + if target_h < 1: + raise ValueError("target_h 必须 >= 1") + rgba = rgba.convert("RGBA") + x0, y0, x1, y1 = _content_bbox(rgba, alpha_thr) + crop = rgba.crop((x0, y0, x1, y1)) + w, h = crop.size + target_w = max(1, round(w * target_h / h)) + small = crop.resize((target_w, target_h), Image.NEAREST) + + alpha = np.asarray(small)[:, :, 3] + if palette is not None and len(palette): + rgb = _snap_to_palette(np.asarray(small.convert("RGB")), palette) + else: + rgb = np.asarray( + small.convert("RGB") + .quantize(colors=max(2, palette_size), method=Image.FASTOCTREE) + .convert("RGB") + ) + out = np.dstack([rgb, alpha]).astype(np.uint8) + return Image.fromarray(out, "RGBA") + + +def pixelate_frames( + frames: list[Image.Image], + target_h: int = 100, + palette_size: int = 32, + palette: np.ndarray | None = None, + ref_height: float | None = None, +) -> list[Image.Image]: + """批量像素化一组帧,**整段共用一个缩放系数**,便于打包为 sprite sheet。 + + ``target_h`` 是**基准姿态**的目标像素高,其余帧按同一系数等比缩放 —— 不是把每帧都拉 + 到等高。逐帧拉等高会把走路自然的身高起伏反向变成"忽大忽小"(实测踩过:蹲下的帧被放大)。 + + ``ref_height``:**跨动作一致性的关键**。给定时用它当基准(单位=源图像素),否则用本序列 + 最高帧。同一角色的各个动作若各自取自己的最高帧定标,切换状态时角色会忽大忽小 —— + 传入同一个基准(如母版姿态的角色高)即可让 idle/walk/jump/attack 共用一套尺度。 + """ + if not frames: + return [] + box_h = [] + for f in frames: + _, y0, _, y1 = _content_bbox(f.convert("RGBA")) + box_h.append(max(1, y1 - y0)) + scale = target_h / (ref_height if ref_height else max(box_h)) + return [ + to_pixel_art(f, max(1, round(h * scale)), palette_size, palette=palette) + for f, h in zip(frames, box_h) + ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py new file mode 100644 index 00000000..88487645 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py @@ -0,0 +1,69 @@ +"""Root motion(位移轨迹)与逐帧时长 —— 按 2D 游戏业界惯例分离"姿势"与"位移"。 + +业界做法(调研 2026-07-28): +- **位移不烘进序列帧**。连续位移动作几乎一律用 *in-place animation + 引擎代码驱动移动*, + 因为玩家要即时操控:跑动中转向应立刻响应,而不是等一段烘死的位移播完。平台游戏的跳跃 + 也是"几个姿势定格 + 引擎物理驱动上下",不是把抛物线画进像素。 + → 序列帧保持**原地**(脚线对齐),位移单独作为 root-motion 轨道交给引擎。 +- **逐帧时长比帧数更重要**("frame timing beats frame count")。业界常用: + idle 400–500ms/帧、walk 100–150ms、run 80–100ms、attack 起手 80–100ms 且**触点定格 + 150–200ms**。全程等时长会让动作发飘、没有重量感。 + +本模块只做几何与时长计算,纯 numpy,零 API。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["extract_root_motion", "frame_durations", "DEFAULT_FPS_MS"] + +# 各动作的基准单帧时长(ms),取业界常用区间的中值。 +DEFAULT_FPS_MS = { + "idle": 450, + "walk": 125, + "run": 90, + "jump": 110, + "attack": 90, + "hit": 90, +} + + +def extract_root_motion(frames: list[Image.Image], alpha_thr: int = 128) -> list[tuple[int, int]]: + """逐帧相对首帧的 (dx, dy) 位移,单位=像素,y 向上为正。 + + 以主体包围盒的**底边中心**(脚点)为参考点。序列帧本身保持原地时,这条轨道就是引擎 + 要施加的 root motion:jump 的 dy 是腾空高度,walk 的 dx 是前进量。 + """ + pts: list[tuple[float, float]] = [] + for f in frames: + a = np.asarray(f.convert("RGBA")) + ys, xs = np.where(a[:, :, 3] > alpha_thr) + pts.append(((xs.min() + xs.max()) / 2, float(ys.max())) if len(ys) else (np.nan, np.nan)) + arr = np.array(pts, dtype=np.float32) + if np.isnan(arr).any(): # 空帧用邻近值补 + idx = np.arange(len(arr)) + for c in range(2): + good = ~np.isnan(arr[:, c]) + arr[:, c] = np.interp(idx, idx[good], arr[good, c]) if good.any() else 0.0 + base = arr[0] + return [(int(round(p[0] - base[0])), int(round(base[1] - p[1]))) for p in arr] + + +def frame_durations( + action: str, n_frames: int, key_frame: int | None = None, hold_ms: int = 180 +) -> list[int]: + """逐帧时长(ms)。关键帧(触点 / 顶点)加长定格,其余用该动作的基准时长。 + + Args: + action: 动作名(取 :data:`DEFAULT_FPS_MS` 的基准时长,未知动作按 walk)。 + n_frames: 帧数。 + key_frame: 要定格的帧下标(attack 的触点、jump 的顶点);None 表示全程等时长。 + hold_ms: 关键帧时长,业界常用 150–200ms。 + """ + base = DEFAULT_FPS_MS.get(action, DEFAULT_FPS_MS["walk"]) + out = [base] * max(0, n_frames) + if key_frame is not None and 0 <= key_frame < n_frames: + out[key_frame] = max(base, hold_ms) + return out diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py new file mode 100644 index 00000000..6eebe65b --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py @@ -0,0 +1,16 @@ +"""prompt:各动作的生成提示词与装配。""" + +from .actions import build_attack_prompt, build_custom_prompt, build_idle_prompt +from .jump import JUMP_PHASES, build_jump_prompt +from .walk import WALK_BODY_FRONT, WALK_BODY_SIDE, build_walk_prompt + +__all__ = [ + "WALK_BODY_SIDE", + "WALK_BODY_FRONT", + "build_walk_prompt", + "JUMP_PHASES", + "build_jump_prompt", + "build_idle_prompt", + "build_attack_prompt", + "build_custom_prompt", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py new file mode 100644 index 00000000..f2466202 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -0,0 +1,111 @@ +"""待机 / 攻击 i2v 提示词。 + +措辞迁自 windup-pipeline 已验证的 prompt_library(idle / slash),按本模块的 facing 分流改写。 + +- **idle**:循环类(tail_match)。只写躯干呼吸节律,武器与双脚显式锁定 —— 逐帧生成待机 + 只会抖不会呼吸,故走 i2v 或程序化 Idle-B。 +- **attack**:一次性类。四条已验证的锁定:①"one single committed motion"防复读; + ②剑长与握点固定;③剑在身前、刃面朝观者(防 Z 轴穿模与刀刃翻转);④终态回戒备并保持。 + 节奏(蓄力慢/挥砍快/触点定格)在抽帧做,不写进 prompt。 +""" + +from __future__ import annotations + +__all__ = ["build_idle_prompt", "build_attack_prompt", "build_custom_prompt"] + +_IDLE_SIDE = ( + "The character stands in place, seen from the side facing right: the chest breathes in one " + "slow, even rhythm, the ribcage expanding and easing back while the shoulders stay level and " + "settled at the same height, the torso rising and lowering in that same slow rhythm, " + "{weapon} resting steady at the side in a fixed grip, {garment} hanging and swaying in the " + "same rhythm, both boots planted firmly on the ground, weight centered, the character stays " + "in the same spot and keeps facing right." +) + +_IDLE_FRONT = ( + "The character stands in place facing the viewer: the chest breathes in one slow, even " + "rhythm, the ribcage expanding and easing back while the shoulders stay level and settled at " + "the same height, the torso rising and lowering in that same slow rhythm, {weapon} resting " + "steady at the side in a fixed grip, {garment} hanging and swaying in the same rhythm, both " + "boots planted firmly on the ground, weight centered, the character keeps FACING THE VIEWER " + "and stays in the same spot." +) + +_ATTACK_SIDE = ( + "Seen from the side facing right, the character makes ONE single committed attack, staying in " + "STRICT SIDE VIEW the whole time: starting coiled with the weight on the back foot, the body " + "leans forward and the weight surges onto the front foot, the arm sweeping {weapon} through " + "one smooth downward crescent arc from high behind the shoulder down across the front to full " + "extension low, {weapon} keeping its exact length and grip position and staying clearly in " + "front of the body with its flat side facing the viewer the whole way, {garment} swinging with " + "the motion, then the body settles back upright into guard and holds that stance, standing " + "steady. The torso and hips keep pointing to the right the entire time and the character never " + "turns toward or away from the viewer." +) + +_ATTACK_FRONT = ( + "Facing the viewer, the character makes ONE single committed attack: starting coiled with the " + "weight on the back foot, the whole body uncoils forward, the arm sweeping {weapon} through " + "one smooth arc across the front to full extension, {weapon} keeping its exact length and grip " + "position and staying clearly in front of the body with its flat side facing the viewer the " + "whole way, {garment} swinging with the motion, then the body settles back upright into guard " + "and holds that stance, standing steady and keeping FACING THE VIEWER." +) + +DEFAULT_WEAPON = "the sword" +DEFAULT_GARMENT = "the cape" + + +def _build(side: str, front: str, weapon: str, garment: str, feet: str, facing: str) -> str: + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + body = (side if facing == "side" else front).format(weapon=weapon, garment=garment) + return body.replace("boot", feet) if feet != "boot" else body + + +def build_idle_prompt( + weapon: str = DEFAULT_WEAPON, + garment: str = DEFAULT_GARMENT, + feet: str = "boot", + facing: str = "side", +) -> str: + """待机正文(循环类)。``facing`` 须与母版朝向一致。""" + return _build(_IDLE_SIDE, _IDLE_FRONT, weapon, garment, feet, facing) + + +def build_attack_prompt( + weapon: str = DEFAULT_WEAPON, + garment: str = DEFAULT_GARMENT, + feet: str = "boot", + facing: str = "side", +) -> str: + """攻击正文(一次性类)。``facing`` 须与母版朝向一致。""" + return _build(_ATTACK_SIDE, _ATTACK_FRONT, weapon, garment, feet, facing) + + +_CUSTOM_SIDE = ( + "Seen from the side facing right, the character performs ONE continuous motion in a STRICT " + "SIDE VIEW the whole time: {action} in one smooth, repetitive rhythm, the torso and hips " + "keeping pointing to the right, feet staying planted in place, the character never turning " + "toward or away from the viewer, then holding the final pose steady at the end." +) + +_CUSTOM_FRONT = ( + "Facing the viewer, the character performs ONE continuous motion in a FRONT VIEW the whole " + "time: {action} in one smooth, repetitive rhythm, feet staying planted in place, the character " + "keeps FACING THE VIEWER and never turns away, then holding the final pose steady at the end." +) + + +def build_custom_prompt(action: str, facing: str = "side") -> str: + """自定义动作正文(一次性类,视频路线)。 + + ``action`` 为自然语言动作描述(如 "painting on an easel with a brush")。 + ``facing`` 须与母版朝向一致。 + """ + if not action or not action.strip(): + raise ValueError("custom 动作需要动作描述(action_desc)") + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + body = (_CUSTOM_SIDE if facing == "side" else _CUSTOM_FRONT).format(action=action.strip()) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py new file mode 100644 index 00000000..dac08595 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -0,0 +1,63 @@ +"""跳跃 i2v 提示词(一次性动作,非循环)。 + +与 walk/run 的根本差别: +- **不循环**。跳跃是一段有始有终的动作,不能像步态那样抽单周期闭环。 +- **要拆状态**。游戏里跳跃是状态机:蓄力 → 上升 → 顶点 → 下降 → 落地缓冲;悬空时长由 + 物理决定、上升中可被打断,所以必须能分段播放,不能烘成一整段。 +- 提示词要写**"只做一次 + 终态保持"**,防 5s 内复读跳第二次(实测:写了仍会复读,故抽帧层 + 另有 first_action_end 兜底)。 +- **原地起跳、幅度适中**:水平位移交引擎做 root-motion,不烘进像素;幅度过大会让角色顶出 + 视频画面,且序列帧里角色被缩得很小。 + +朝向同 walk:必须与母版一致(side 横版 / front 俯视·2.5D)。 +""" + +from __future__ import annotations + +__all__ = ["JUMP_BODY_SIDE", "JUMP_BODY_FRONT", "JUMP_PHASES", "build_jump_prompt"] + +# 跳跃的五个状态(引擎侧按这个切段;顺序即时间顺序)。 +JUMP_PHASES = ("crouch", "rise", "apex", "fall", "land") + +JUMP_BODY_SIDE = ( + "The character performs ONE single jump in place, seen from the side facing right: " + "first the knees bend deep into a crouch and the arms drop back, then both boots push " + "off the ground and the whole body lifts straight upward a modest height with the legs " + "tucking up, the body reaches the top of the jump and hangs there for an instant with {garment} " + "floating upward, then the body falls back down with the legs reaching for the ground, " + "and both boots land together with the knees bending to absorb the impact, the weapon " + "stays held steady in a fixed grip the whole time. The character does this ONCE and " + "then stays standing upright in the landing spot, staying centered in frame." +) + +JUMP_BODY_FRONT = ( + "The character performs ONE single jump in place, facing the viewer: first the knees " + "bend deep into a crouch and the arms drop back, then both boots push off the ground " + "hard and the whole body launches straight upward with the knees tucking up toward the " + "camera, the body reaches the top of the jump and hangs there for an instant with " + "{garment} floating upward, then the body falls back down with the legs reaching for " + "the ground, and both boots land together with the knees bending to absorb the impact, " + "the weapon stays held steady in a fixed grip the whole time. The character keeps " + "FACING THE VIEWER, does this ONCE and then stays standing upright, centered in frame." +) + +DEFAULT_GARMENT = "the cape and tabard" + + +def build_jump_prompt( + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" +) -> str: + """按角色装备 + 母版朝向生成跳跃正文。 + + Args: + garment: 起跳时上飘的衣饰。 + feet: 落脚部件用词(替换 boot)。 + facing: "side" 或 "front",**必须与母版朝向一致**。 + """ + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + template = JUMP_BODY_SIDE if facing == "side" else JUMP_BODY_FRONT + body = template.format(garment=garment) + if feet != "boot": + body = body.replace("boot", feet) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py new file mode 100644 index 00000000..5d3c4a6e --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -0,0 +1,57 @@ +"""走路 i2v 提示词(视频路线)。 + +实测要点(Issue #35): +- 只写正向词、逐条写腿部可见动作(抬 / 摆 / 蹬 / 承重),锁死手持武器不乱动。 +- **提示词的朝向必须与母版朝向一致**。给正面母版喂侧走词(STRICT SIDE)会让模型靠"转身" + 调和图文矛盾——早期"正面母版必转身"的结论正是这么造成的。故按 facing 分流: + side(横版侧走)/ front(俯视·2.5D 朝观者行进),对应 Project.perspective。 +- "半侧"母版(头侧脸 + 身体略正)配 side 词,实测会被自然解析成正侧面走,不转身,够用。 +- 换角色只替换装备子句(如 骷髅:boot→骨足、cape→围巾),机制词保持不变。 +""" + +from __future__ import annotations + +__all__ = ["WALK_BODY_SIDE", "WALK_BODY_FRONT", "DEFAULT_GARMENT", "build_walk_prompt"] + +# 侧走(横版):整体向右推进 + 锁侧视。 +WALK_BODY_SIDE = ( + "The character walks steadily to the right through the open space, the whole body " + "advancing with every stride: the front boot lifts, swings forward and plants heel " + "first, the rear boot pushes off the ground, the hips and torso carry the weight " + "forward over the planted foot, {garment} swing with the steps, the weapon stays held " + "low and steady at the side in a fixed grip, the upper body stays calm and upright, " + "SIDE VIEW facing right the whole time, the legs clearly visible." +) + +# 正面走(俯视 / 2.5D):朝观者原地行进,身体始终正对观者、不转身。 +WALK_BODY_FRONT = ( + "The character walks in place toward the viewer, marching forward on the spot: each " + "boot lifts, swings forward and plants down in turn while the other pushes off, the " + "knees rise alternately toward the camera, the hips and shoulders sway naturally with " + "each step, {garment} sway with the steps, the weapon stays held low and steady in a " + "fixed grip, the upper body stays calm and upright, the character keeps FACING THE " + "VIEWER the whole time and stays centered in frame, both legs clearly visible." +) + +# 每个角色只替换 garment / feet 两处装备子句,机制词不动。 +DEFAULT_GARMENT = "the cape and tabard" + + +def build_walk_prompt( + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" +) -> str: + """按角色装备 + 母版朝向生成走路正文。 + + Args: + garment: 随步伐摆动的衣饰(如 "the cape and tabard" / "the red scarf and tabard")。 + feet: 落脚部件用词(如 "boot" / "bare bony foot"),替换机制句里的 boot。 + facing: "side"(横版侧走,母版朝侧向)或 "front"(俯视/2.5D,母版朝观者)。 + **必须与母版朝向一致**,否则模型会靠转身调和矛盾。 + """ + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + template = WALK_BODY_SIDE if facing == "side" else WALK_BODY_FRONT + body = template.format(garment=garment) + if feet != "boot": + body = body.replace("boot", feet) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py new file mode 100644 index 00000000..3489de23 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py @@ -0,0 +1,27 @@ +"""slicing:视频 → 帧序列。抽帧(extract)+ 选帧(周期 loop / 一次性 oneshot)。 + +视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝 +loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。 +""" + +from .extract import extract_all_frames_bytes, extract_frames_bytes +from .loop import find_period, pick_cycle +from .oneshot import ( + find_motion_span, + first_action_end, + foot_line_series, + pick_oneshot, + split_jump_phases, +) + +__all__ = [ + "extract_frames_bytes", + "extract_all_frames_bytes", + "find_period", + "pick_cycle", + "find_motion_span", + "first_action_end", + "foot_line_series", + "pick_oneshot", + "split_jump_phases", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py new file mode 100644 index 00000000..1a7bc781 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py @@ -0,0 +1,162 @@ +"""视频抽帧(切片层的解码入口)。 + +承接视频路线(Issue #35):i2v 产出的短视频步态真实但为插画质感。本模块只负责 +把视频 bytes 解码成帧序列;选帧(周期 / 一次性)见 :mod:`.loop` / :mod:`.oneshot`, +像素化 / 对齐 / 打包见 :mod:`..postprocess`。抽帧后端(imageio/ffmpeg)函数内惰性, +模块导入零成本、CI 可收集。 +""" + +from __future__ import annotations + +import logging +import os +import shutil +import tempfile +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout + +from PIL import Image + +logger = logging.getLogger("windup.slicing.extract") + +_EXTRACT_TIMEOUT_SECONDS = 180 # 单次抽帧方案超时:3 分钟 + +__all__ = ["extract_frames_bytes", "extract_all_frames_bytes"] + + +def extract_frames_bytes(video: bytes, n: int) -> list[Image.Image]: + """从视频 bytes 均匀抽 ``n`` 帧(供后端 strategy 用,provider 返回的是 bytes)。""" + path = tempfile.mktemp(suffix=".mp4") + try: + with open(path, "wb") as f: + f.write(video) + return _extract_frames(path, n) + finally: + try: + os.unlink(path) + except OSError: + pass + + +def extract_all_frames_bytes(video: bytes, cap: int = 150) -> list[Image.Image]: + """抽视频全部帧(至多 ``cap``,均匀降采样),供周期检测用。""" + path = tempfile.mktemp(suffix=".mp4") + try: + with open(path, "wb") as f: + f.write(video) + return _extract_frames(path, cap) + finally: + try: + os.unlink(path) + except OSError: + pass + + +def _run_with_timeout(fn, *args, timeout: int = _EXTRACT_TIMEOUT_SECONDS): + """在独立线程中执行 *fn*,超时则抛 TimeoutError。 + + 用 ThreadPoolExecutor 而非 signal,兼容 Windows 子线程场景。 + """ + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(fn, *args) + try: + return future.result(timeout=timeout) + except FutureTimeout as exc: + future.cancel() + raise TimeoutError( + f"抽帧耗时超过 {timeout} 秒,已终止" + ) from exc + + +def _imageio_extract(video_path: str, n: int) -> list[Image.Image]: + import imageio.v3 as iio + + all_frames = iio.imread(video_path, plugin="pyav") # (T, H, W, C) + total = len(all_frames) + m = min(n, total) + idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] + return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx] + + +def _pyav_extract(video_path: str, n: int) -> list[Image.Image]: + import av as _av + + container = _av.open(video_path) + all_frames = [] + for frame in container.decode(video=0): + all_frames.append(frame.to_rgb().to_ndarray()) + container.close() + if not all_frames: + return [] + total = len(all_frames) + m = min(n, total) + idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] + return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx] + + +def _ffmpeg_extract(video_path: str, n: int) -> list[Image.Image]: + import glob + import subprocess + + with tempfile.TemporaryDirectory() as tmp: + subprocess.run( + ["ffmpeg", "-y", "-i", video_path, "-vsync", "0", + os.path.join(tmp, "f_%04d.png")], + capture_output=True, check=True, + ) + files = sorted(glob.glob(os.path.join(tmp, "f_*.png"))) + if not files: + raise RuntimeError("抽帧失败:视频无可解码帧") + m = min(n, len(files)) + idx = [round(i * (len(files) - 1) / max(1, m - 1)) for i in range(m)] + return [Image.open(files[i]).convert("RGBA").copy() for i in idx] + + +def _extract_frames(video_path: str, n: int) -> list[Image.Image]: + """从视频均匀抽 ``n`` 帧。优先 imageio,回退 pyav 直调,再回退系统 ffmpeg。 + + 每个方案都有 3 分钟超时保护,避免解码卡死导致后台线程永久挂起。 + """ + errors: list[str] = [] + timeout = _EXTRACT_TIMEOUT_SECONDS + + # 1) imageio + pyav 插件 + try: + return _run_with_timeout(_imageio_extract, video_path, n, timeout=timeout) + except TimeoutError as exc: + logger.warning("imageio 抽帧超时: %s", exc) + errors.append(f"imageio: {exc}") + except Exception as exc: + logger.debug("imageio 抽帧失败,尝试下一方案: %s", exc) + errors.append(f"imageio: {exc}") + + # 2) pyav 直调(绕过 imageio 插件初始化问题) + try: + return _run_with_timeout(_pyav_extract, video_path, n, timeout=timeout) + except TimeoutError as exc: + logger.warning("pyav 抽帧超时: %s", exc) + errors.append(f"pyav: {exc}") + except Exception as exc: + logger.debug("pyav 抽帧失败,尝试下一方案: %s", exc) + errors.append(f"pyav: {exc}") + + # 3) 系统 ffmpeg + if shutil.which("ffmpeg") is None: + errors.append("ffmpeg: 系统未安装 ffmpeg 或不在 PATH 中") + raise RuntimeError( + "视频抽帧失败,所有方案均不可用:\n" + + "\n".join(f" - {e}" for e in errors) + ) + + import subprocess + + try: + return _run_with_timeout(_ffmpeg_extract, video_path, n, timeout=timeout) + except TimeoutError as exc: + logger.warning("ffmpeg 抽帧超时: %s", exc) + raise RuntimeError( + "视频抽帧失败,所有方案均超时或不可用:\n" + + "\n".join(f" - {e}" for e in errors) + ) from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.decode(errors="replace") if exc.stderr else "" + raise RuntimeError(f"ffmpeg 抽帧失败 (exit {exc.returncode}): {stderr}") from exc diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py new file mode 100644 index 00000000..c35faa74 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -0,0 +1,46 @@ +"""循环闭合(最后一公里之一,Issue #21)—— 从 i2v 密集帧里抽正好一个步态周期,做无缝 loop。 + +i2v 的 5s 视频里含 ~2-3 个步态周期,均匀抽 N 帧跨多个周期 → 首尾接缝跳。做法: +帧自相似检测周期(灰度小图,frame[i] 与 frame[i+p] 差最小的 p = 一个周期), +再在一个周期内均匀取 N 帧 → frame[N-1] 的下一拍≈frame[0],循环自然闭合。 +纯 numpy / PIL,零 API。 +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["find_period", "pick_cycle"] + +_SMALL = 48 # 周期检测用的灰度小图边长 + + +def _gray(frames: list[Image.Image]) -> list[np.ndarray]: + return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames] + + +def find_period(frames: list[Image.Image], pmin: int | None = None, pmax: int | None = None) -> int: + """自相似求步态周期(帧数)。frame[i] 与 frame[i+p] 平均差最小的 p。""" + n = len(frames) + gs = _gray(frames) + pmin = pmin or max(4, n // 6) + pmax = pmax or max(pmin + 1, n // 2) + best_p, best_d = pmin, float("inf") + for p in range(pmin, pmax + 1): + d = float(np.mean([np.abs(gs[i] - gs[i + p]).mean() for i in range(n - p)])) + if d < best_d: + best_d, best_p = d, p + return best_p + + +def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: + """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。帧数不足则原样返回。""" + total = len(frames) + if total <= n: + return frames + gs = _gray(frames) + p = find_period(frames) + # 搜起点 i0:让 frame[i0] 与 frame[i0+p] 最像(相位闭合最好)→ 末帧回接首帧最平滑 + i0 = min(range(total - p), key=lambda i: float(np.abs(gs[i] - gs[i + p]).mean())) + idx = [(i0 + round(k * p / n)) % total for k in range(n)] + return [frames[i] for i in idx] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py new file mode 100644 index 00000000..990604bf --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py @@ -0,0 +1,189 @@ +"""一次性动作(jump / attack / hit)的抽帧:裁动作起止 + 按状态切段。 + +与循环类(idle/walk/run)的根本差别: +- 循环类用 :mod:`.loop` 找步态周期抽单周期闭环;一次性动作**不能闭环** —— 首尾姿态不同, + 强行闭环会把落地帧接回蓄力帧,读起来是抽搐。 +- i2v 出的 5s 视频里,真正的动作往往只占中间一段(前后是静止的起手/终态保持),直接均匀 + 抽帧会浪费一半帧在不动的地方 → 需要先**裁到动作发生的区间**。 +- jump 还要进一步**按状态切段**(蓄力/上升/顶点/下降/落地),因为引擎里悬空时长由物理 + 决定、上升中可被打断,必须能分段播放。 + +纯 numpy / PIL,零 API。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = [ + "find_motion_span", + "first_action_end", + "pick_oneshot", + "split_jump_phases", + "foot_line_series", +] + + +def _frame_energy(frames: list[Image.Image], size: int = 64) -> np.ndarray: + """逐帧与前一帧的差异强度(灰度小图),长度 = len(frames)-1。""" + gs = [np.asarray(f.convert("L").resize((size, size)), dtype=np.float32) for f in frames] + return np.array([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)]) + + +def find_motion_span(frames: list[Image.Image], rel_thr: float = 0.25) -> tuple[int, int]: + """定位"动作真正发生"的帧区间 ``[start, end]``(含端点)。 + + 以帧间差异强度超过峰值 ``rel_thr`` 倍的最早/最晚位置为界,并各留一帧余量。 + 静止的起手与终态保持会被裁掉。 + """ + if len(frames) < 3: + return 0, len(frames) - 1 + e = _frame_energy(frames) + peak = float(e.max()) + if peak <= 1e-6: + return 0, len(frames) - 1 + active = np.flatnonzero(e >= peak * rel_thr) + if not len(active): + return 0, len(frames) - 1 + start = max(0, int(active[0]) - 1) + end = min(len(frames) - 1, int(active[-1]) + 2) + return start, end + + +def _airborne_end(frames: list[Image.Image], start: int, end: int, tol: float = 6.0) -> int: + """腾空类(jump)的结束:脚线越过最高点后**首次回到地面**。 + + 几何信号,明确无歧义 —— 比任何"能量安静"判据都稳。 + """ + y = foot_line_series(frames[start : end + 1]) + if len(y) < 4: + return end + apex = int(np.argmin(y)) + ground = float(np.median([y[0], y[-1]])) + back = np.flatnonzero(y[apex:] >= ground - tol) + return min(end, start + apex + int(back[0]) + 2) if len(back) else end + + +def _swing_end(frames: list[Image.Image], start: int, end: int, + drop_ratio: float = 0.35, recover: int = 2) -> int: + """挥击类(attack/hit)的结束:能量越过峰值后**首次跌到峰值的 ``drop_ratio``**,再留收势余量。 + + 挥击是"蓄力 → 峰值 → 收势"的单峰结构,收势很短,故用"跌破比例 + 固定余量"即可; + 不要求长时间静止 —— 实测挥砍收势段的能量并不干净(视频压缩噪点),等不到静止平台。 + """ + e = _frame_energy(frames[start : end + 1]) + if len(e) < 4: + return end + peak_i = int(np.argmax(e)) + thr = float(e.max()) * drop_ratio + for i in range(peak_i + 1, len(e)): + if e[i] < thr: + return min(end, start + i + recover) + return end + + +def first_action_end( + frames: list[Image.Image], start: int, end: int, kind: str = "swing" +) -> int: + """在 ``[start, end]`` 内找**第一次**动作的结束帧,按动作物理分流。 + + i2v 常在 5s 里把一次性动作**复读第二遍**(实测:提示词写了 "ONCE",兽人跳了两次、 + 挥砍也挥了两次),不裁会把两次动作压进一套序列帧。 + + 不同动作的"结束"信号本质不同,**一个通用判据管不了两种**(实测踩过): + - ``kind="airborne"``(jump):脚线回到地面 —— 几何、无歧义。 + - ``kind="swing"``(attack/hit):能量跌破峰值比例 + 收势余量。 + + 三个已验证无效的通用解法(别再试):①只看"帧间安静" → 在跳跃**顶点悬停**处误触发, + 把动作截在半空;②要求静止段足够长 → 挥砍收势并不干净(压缩噪点),等不到,完全不裁; + ③找"回到起始姿态"的谷底 → 收势姿态(戒备)与起始姿态(蓄力)不同,回不到低位。 + """ + if end - start < 4: + return end + return (_airborne_end if kind == "airborne" else _swing_end)(frames, start, end) + + +def pick_oneshot( + frames: list[Image.Image], n: int, first_only: bool = True, kind: str = "swing" +) -> list[Image.Image]: + """一次性动作抽 ``n`` 帧:裁到动作区间 → 只留第一次动作 → 区间内均匀取(不闭环)。 + + ``first_only`` 默认开:防 i2v 在 5s 内复读第二遍动作被一起抽进来。 + ``kind``:``"airborne"``(jump,按脚线回地判结束)或 ``"swing"``(attack/hit,按能量跌破判)。 + """ + if len(frames) <= n: + return frames + start, end = find_motion_span(frames) + if first_only: + end = max(start + 1, first_action_end(frames, start, end, kind=kind)) + span = frames[start : end + 1] + if len(span) <= n: + return span + idx = [round(i * (len(span) - 1) / (n - 1)) for i in range(n)] + return [span[i] for i in idx] + + +def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray: + """主体所在的行下标。有真实 alpha 用 alpha;**全不透明帧**(原始视频帧)按四角背景色判。 + + 必须兼容不透明帧:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把整幅当主体、 + 脚线恒定,导致腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 + """ + arr = np.asarray(frame.convert("RGBA")) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: + return np.where(alpha > alpha_thr)[0] + rgb = arr[:, :, :3].astype(np.int16) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + bg = np.median(corners, axis=0) + return np.where(np.abs(rgb - bg).sum(axis=2) > bg_tol)[0] + + +def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray: + """逐帧主体**底边** y 坐标(脚线)。跳跃时脚线先降(蹲)、再升(腾空)、再落回。""" + out = [] + for f in frames: + ys = _subject_rows(f, alpha_thr) + out.append(float(ys.max()) if len(ys) else np.nan) + arr = np.array(out, dtype=np.float32) + if np.isnan(arr).any(): # 空帧用邻近值补 + idx = np.arange(len(arr)) + good = ~np.isnan(arr) + if good.any(): + arr = np.interp(idx, idx[good], arr[good]) + else: + arr = np.zeros_like(arr) + return arr + + +def split_jump_phases(frames: list[Image.Image]) -> dict[str, list[int]]: + """按脚线轨迹把跳跃切成 crouch / rise / apex / fall / land 五段,返回每段的帧下标。 + + 判据:脚线 y 越小 = 人越高。最高点(y 最小)即 apex;起跳前脚线最低(蹲)处为 crouch + 结束;之后到 apex 为 rise,apex 之后到脚线回到地面高度为 fall,余下为 land。 + 只依赖几何,不依赖模型。 + """ + n = len(frames) + if n < 5: + return {"rise": list(range(n))} + y = foot_line_series(frames) + apex = int(np.argmin(y)) # 最高点 + ground = float(np.median([y[0], y[-1]])) # 地面脚线 + # 起跳点:apex 之前脚线最低(数值最大 = 蹲得最深)的位置 + takeoff = int(np.argmax(y[: max(1, apex)])) if apex > 0 else 0 + # 落地点:apex 之后脚线首次回到地面附近 + after = y[apex:] + back = np.flatnonzero(after >= ground - 2) + landing = apex + int(back[0]) if len(back) else n - 1 + + apex_lo = max(takeoff + 1, apex - 1) + apex_hi = min(landing - 1, apex + 1) + phases = { + "crouch": list(range(0, takeoff + 1)), + "rise": list(range(takeoff + 1, apex_lo)), + "apex": list(range(apex_lo, apex_hi + 1)), + "fall": list(range(apex_hi + 1, landing)), + "land": list(range(landing, n)), + } + return {k: v for k, v in phases.items() if v} diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py new file mode 100644 index 00000000..bf985a9d --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py @@ -0,0 +1,12 @@ +"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。""" + +from .base import ROUTE_MATRIX, DerivationStrategy +from .concrete import PerFrameStrategy, ProcIdleStrategy, VideoFrameStrategy + +__all__ = [ + "ROUTE_MATRIX", + "DerivationStrategy", + "VideoFrameStrategy", + "PerFrameStrategy", + "ProcIdleStrategy", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py new file mode 100644 index 00000000..492a4029 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -0,0 +1,51 @@ +"""DerivationStrategy —— 按动作类型分流到生成路线(本营实测挣得的核心架构决策)。 + +分流依据(有实测证据,非拍脑袋,详见关联 Issue #35 的工程文档): + - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞; + 必须走视频 i2v(视频模型天生连贯、腿自然交替)。 + - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环 + (见 strategy.concrete.CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。 + - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。 + - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。 + +ROUTE_MATRIX 是人主导的架构契约,改它=改产线,要有实测支撑。 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute + +from windup_ai_engine.ports import ProgressPort + +# 动作类型 → 生成路线(架构决策,写死为契约) +ROUTE_MATRIX: dict[ActionType, GenRoute] = { + ActionType.WALK: GenRoute.VIDEO_I2V, + ActionType.RUN: GenRoute.VIDEO_I2V, + ActionType.JUMP: GenRoute.VIDEO_I2V, + ActionType.ATTACK: GenRoute.VIDEO_I2V, + ActionType.HIT: GenRoute.PER_FRAME, + # idle 走 i2v(build_idle_prompt:躯干缓慢起伏呼吸)——"快速看着对"的待机路线。 + # ¥0 的程序化 Idle-B(局部网格呼吸)是后续可选优化,当前 ProcIdleStrategy 仍是桩。 + ActionType.IDLE: GenRoute.VIDEO_I2V, + # custom:提示词驱动的自定义动作(如"在画板上作画")。走视频路线,动作描述 + # 由 ActionSpec.action_desc 提供;一次性动作,不闭环(见 CYCLIC_ACTIONS)。 + ActionType.CUSTOM: GenRoute.VIDEO_I2V, +} + + +class DerivationStrategy(ABC): + """一条生成路线的骨架:母版 → 对齐前的角色帧序列。""" + + route: GenRoute + + @abstractmethod + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + """从母版 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。""" + raise NotImplementedError diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py new file mode 100644 index 00000000..3853809a --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -0,0 +1,168 @@ +"""三条 DerivationStrategy。 + +- VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。 +- PerFrameStrategy / ProcIdleStrategy:桩,待开发(见 #53,per-frame / idle 非首个竖线)。 + +VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 → +matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。 +""" +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute +from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider + +from windup_ai_engine.master_prep import prepare_master +from windup_ai_engine.ports import ProgressPort +from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames +from windup_ai_engine.slicing import extract_all_frames_bytes, pick_cycle, pick_oneshot +from windup_ai_engine.prompt import ( + build_attack_prompt, + build_custom_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) +from windup_ai_engine.strategy.base import DerivationStrategy + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def _img(png: bytes) -> Image.Image: + return Image.open(io.BytesIO(png)).convert("RGBA") + + +# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 +# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。 +CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN}) + + +class VideoFrameStrategy(DerivationStrategy): + """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。 + + 覆盖循环类(walk/run)与一次性类(jump/attack)——按 :data:`CYCLIC_ACTIONS` 分流抽帧方式。 + 硬前提:**提示词朝向必须与母版一致**(side/front);给正面母版喂侧走词会让模型靠转身 + 调和图文矛盾(实测 #35)。 + """ + + route = GenRoute.VIDEO_I2V + + def __init__(self, video: VideoProvider, matte: MatteProvider) -> None: + self._video = video + self._matte = matte + + def _build_prompt(self, action: ActionSpec) -> str: + """按动作类型选提示词;朝向随 ActionSpec.facing。""" + if action.action is ActionType.CUSTOM: + return build_custom_prompt(action.action_desc, facing=action.facing) + builders = { + ActionType.JUMP: build_jump_prompt, + ActionType.IDLE: build_idle_prompt, + ActionType.ATTACK: build_attack_prompt, + } + build = builders.get(action.action, build_walk_prompt) + return build(facing=action.facing) + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + n = action.n_frames or 8 + progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频") + # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁 + framed = prepare_master(master, action.action.value) + video = self._video.i2v(framed, self._build_prompt(action), seconds=5) + + dense = extract_all_frames_bytes(video) + # 跨动作一致性:用视频首帧(=母版姿态)的角色高当共同定标基准。各动作都从同一母版 + # 起手,故此值一致 —— 否则各动作按自己最高帧定标,切状态时角色会忽大忽小。 + ref_h = None + if dense: + _first = _img(self._matte.cutout(_png(dense[0]))) + _ys, _ = np.where(np.asarray(_first)[:, :, 3] > 128) + ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None + if action.action in CYCLIC_ACTIONS: + progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图") + picked = pick_cycle(dense, n) # 单周期闭环(#21) + else: + progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图") + kind = "airborne" if action.action is ActionType.JUMP else "swing" + picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止 + cut = [_img(self._matte.cutout(_png(im))) for im in picked] + + # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); + # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板, + # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。 + if action.stylize == "none": + progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)") + return [_png(im) for im in cut] + + target_h, palette = action.pixel_h, None + try: + logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的 + if logical_h > 8: # 母版确为像素画 → 按它的规格走 + target_h, palette = logical_h, pal + except Exception: # 母版非像素画/量不出 → 回退通用量化 + pass + progress.step( + "derive", 2, 3, + f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})", + ) + pix = pixelate_frames( + cut, target_h=target_h, palette_size=action.palette_size, + palette=palette, ref_height=ref_h, + ) + return [_png(p) for p in pix] + + +class PerFrameStrategy(DerivationStrategy): + """离散姿势(hit 等,需单帧可编辑):逐帧图生图 → 抠图。桩,待开发(#53)。""" + + route = GenRoute.PER_FRAME + + def __init__(self, image: ImageProvider, matte: MatteProvider) -> None: + self._image = image + self._matte = matte + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + progress.step("derive", 0, 1, f"{action.action}: 逐帧图生图") + # TODO(dev, #53): 逐 pose image.gen_image(母版, pose) → matte.cutout(不加骨架) + return [b"" for _ in range(action.n_frames)] # 桩 + + +class ProcIdleStrategy(DerivationStrategy): + """待机(idle):母版抠图 → 程序化局部躯干呼吸(Idle-B,零 API)。桩,待开发(#53)。""" + + route = GenRoute.PROC_IDLE + + def __init__(self, image: ImageProvider, matte: MatteProvider) -> None: + self._image = image + self._matte = matte + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + progress.step("derive", 0, 1, f"{action.action}: Idle-B 程序化呼吸") + # TODO(dev, #53): 母版抠图 → 躯干带保体积缩放,腿冻结 + return [b"" for _ in range(action.n_frames)] # 桩 diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 89f7b43d..c248689e 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -1,15 +1,97 @@ + """FastAPI 应用工厂与装配入口。 ``create_app`` 负责创建 FastAPI 实例并挂载路由 / 中间件 / 异常处理, 是整个 web 服务的唯一装配点(composition root)。 + +``main`` 是开发启动入口:``python -m windup_app`` 或 ``windup`` 命令。 """ +import os +from contextlib import asynccontextmanager + +import windup_framework.db # noqa: F401 组装时显式触发 DB engine/session 初始化 from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from windup_app.server.generation.executor import run_action_task, run_image_task +from windup_app.web.api.character import router as character_router +from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router +from windup_app.web.api.playtest_inspection import router as playtest_inspection_router +from windup_app.web.api.project import router as project_router +from windup_app.web.handler.exception_handlers import register_exception_handlers + + +def _env_flag(name: str) -> bool: + """把环境变量解析为真正的布尔值:仅 1/true/yes/on(忽略大小写与空白)视为 True。""" + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def print_banner() -> None: + """启动时打印 banner(占位实现,后续替换为正式 ASCII banner)。""" + print("windup 0.1.0 starting ...") + + +@asynccontextmanager +async def _lifespan(app: FastAPI): + """应用启动时打印 banner,关闭时无特殊处理。""" + print_banner() + yield def create_app() -> FastAPI: - app = FastAPI(title="windup", version="0.1.0") + app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) + + # 本地开发 CORS: 允许前端 localhost 跨域访问 + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", + "http://localhost:5176", + "http://localhost:5177", + "http://127.0.0.1:5173", + "http://127.0.0.1:5174", + "http://127.0.0.1:5175", + "http://127.0.0.1:5176", + "http://127.0.0.1:5177", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(project_router) + app.include_router(character_router) app.include_router(media_router) + app.include_router(generation_router) + app.include_router(playtest_inspection_router) + # 生成后台调度器注入 app.state:bootstrap(composition root)持有 ai_engine 依赖, + # web 端运行期从 request.app.state 取,避免 web 静态 import ai_engine(入口层门禁)。 + app.state.run_action_task = run_action_task + app.state.run_image_task = run_image_task + register_exception_handlers(app) return app + + +def main() -> None: + """开发启动入口:用 uvicorn 跑 ``create_app``。 + + host/port/reload 可用 ``WINDUP_HOST`` / ``WINDUP_PORT`` / ``WINDUP_RELOAD`` 覆盖。 + """ + import uvicorn + + uvicorn.run( + "windup_app.bootstrap.app:create_app", + factory=True, + host=os.getenv("WINDUP_HOST", "127.0.0.1"), + port=int(os.getenv("WINDUP_PORT", "8000")), + reload=_env_flag("WINDUP_RELOAD"), + ) + + + +if __name__ == "__main__": + main() diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py index 5ea1c134..7106db90 100644 --- a/backend/packages/app/src/windup_app/server/character/model.py +++ b/backend/packages/app/src/windup_app/server/character/model.py @@ -91,12 +91,20 @@ class Character(Base): # ── character_data Pydantic 模型 ────────────────────────────────────────────── +class CharacterRootMotion(BaseModel): + """单帧相对动作首帧的根位移。""" + + dx: float + dy: float + + class CharacterFrame(BaseModel): """动作帧。""" index: int = Field(ge=0, description="帧序号") image_url: str = Field(..., description="帧图片 URL") duration_ms: int | None = Field(default=None, gt=0, description="帧时长(毫秒)") + root_motion: CharacterRootMotion | None = Field(default=None, description="根位移增量") class CharacterAction(BaseModel): @@ -125,4 +133,4 @@ class CharacterData(BaseModel): """角色完整数据(造型→动作→帧)。""" version: int = Field(default=1, description="结构版本") - outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") \ No newline at end of file + outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py new file mode 100644 index 00000000..e1ff22d4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -0,0 +1,69 @@ +"""角色领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyCharacterService` 继承 :class:`CharacterService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from windup_app.server.character.interface import CharacterService +from windup_app.server.character.model import Character + + +class SqlAlchemyCharacterService(CharacterService): + """基于 SQLAlchemy session 的角色 CRUD 实现。""" + + def create_character(self, session: Session, **fields) -> Character: + character = Character(**fields) + session.add(character) + session.flush() + return character + + def get_character(self, session: Session, character_id: int) -> Character | None: + return session.get(Character, character_id) + + def list_characters( + self, session: Session, *, project_id: int, page: int, page_size: int, + ) -> tuple[list[Character], int]: + count_stmt = ( + select(func.count()) + .select_from(Character) + .where(Character.project_id == project_id) + ) + stmt = ( + select(Character) + .where(Character.project_id == project_id) + .order_by(Character.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + total = session.scalar(count_stmt) or 0 + items = list(session.scalars(stmt)) + return items, total + + def update_character( + self, session: Session, character_id: int, **fields, + ) -> Character | None: + character = session.get(Character, character_id) + if character is None: + return None + for key, value in fields.items(): + setattr(character, key, value) + session.flush() + return character + + def delete_character(self, session: Session, character_id: int) -> bool: + character = session.get(Character, character_id) + if character is None: + return False + session.delete(character) + session.flush() + return True + + +service = SqlAlchemyCharacterService() diff --git a/backend/packages/app/src/windup_app/server/generation/__init__.py b/backend/packages/app/src/windup_app/server/generation/__init__.py index a6701deb..e1d88118 100644 --- a/backend/packages/app/src/windup_app/server/generation/__init__.py +++ b/backend/packages/app/src/windup_app/server/generation/__init__.py @@ -7,9 +7,12 @@ CharacterActionOutput, CharacterImageInput, GenerationTask, + GenerationTaskRecord, GenerationType, TaskStatus, ) +from windup_app.server.generation.service import service as generation_service +from windup_app.server.generation import task_repo __all__ = [ "ActionType", @@ -18,6 +21,9 @@ "CharacterActionOutput", "CharacterImageInput", "GenerationTask", + "GenerationTaskRecord", "GenerationType", "TaskStatus", + "generation_service", + "task_repo", ] diff --git a/backend/packages/app/src/windup_app/server/generation/executor.py b/backend/packages/app/src/windup_app/server/generation/executor.py new file mode 100644 index 00000000..e3f71229 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/generation/executor.py @@ -0,0 +1,409 @@ +"""动作生成后台编排(调 ai_engine)。 + +编排链:``mark RUNNING → 取母版 → ai_engine 出帧 → 逐帧上传对象存储 → 写回结果/COMPLETED``。 +异常兜底为 FAILED,不抛。 + +**分层**:本模块调 ai_engine,故 web/worker **不得 import 本模块**(否则牵出 ai_engine, +违反"入口层不经 ai_engine 直连"门禁)。由 bootstrap(composition root)import + 注入 +``app.state``,web 端从 ``request.app.state`` 运行期取回调度,不产生静态依赖。 + +依赖(generator / upload / 取母版 / session 工厂)全可注入,缺省用真实实现(懒加载, +避免 import-time 触发 AI 配置)。测试注入桩即可离线跑通,不联网、不碰对象存储。 +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import httpx +from sqlalchemy.orm import Session + +from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard + +from windup_app.server.generation import task_repo +from windup_app.server.generation.model import ( + CharacterActionInput, + CharacterImageInput, + TaskStatus, +) + +if TYPE_CHECKING: + from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort + +logger = logging.getLogger("windup.generation.executor") + +_ACTION_RESULT = "character_action" # task_repo._deserialize_result 按此标签反序列化 + +# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ───────────────────────── +# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角 +_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"} +_PERSPECTIVE_VIEW: dict[int, str] = { + 1: "side view, horizontal side-scroller", + 2: "top-down view", + 3: "2.5D three-quarter view", +} +# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数 +_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8} + + +@dataclass +class ProjectConstraints: + """从 Project 取的全局生成约束,统一约束角色图/动作生成。""" + + facing: str = "side" # character_perspective → 朝向(须与母版一致 #35) + view: str = "side view, horizontal side-scroller" + perspective: int = 1 # 1横版 2俯视 3 2.5D + directions: int = 1 # directional_movement → 方向数(1/4/8) + sprite_w: int = 256 # 输出/切帧尺寸(关键) + sprite_h: int = 256 + style: str = "" # game_style 画风 + stylize: str = "none" # 由 style 推:像素游戏 → pixel + sprite_sample_url: str = "" # 项目风格参考图 URL + + +def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints: + """查 Project 组装全局约束;无 project_id / 查不到 → 缺省。""" + if project_id is None: + return ProjectConstraints() + from windup_app.server.project.service import SqlAlchemyProjectService + + p = SqlAlchemyProjectService().get_project(session, project_id) + if p is None: + return ProjectConstraints() + style = p.game_style or "" + is_pixel = "pixel" in style.lower() or "像素" in style + return ProjectConstraints( + facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"), + view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]), + perspective=p.character_perspective, + directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1), + sprite_w=p.sprite_width, + sprite_h=p.sprite_height, + style=style, + stylize="pixel" if is_pixel else "none", + sprite_sample_url=p.sprite_sample_url or "", + ) + + +def _fit_to(png: bytes, w: int, h: int) -> bytes: + """把帧等比缩放进 w×h(透明补边),落实项目 sprite 尺寸约束。""" + import io + + from PIL import Image + + im = Image.open(io.BytesIO(png)).convert("RGBA") + if im.size == (w, h): + return png + fitted = im.copy() + fitted.thumbnail((w, h), Image.NEAREST) + canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +class _LogProgress: + """进度上报占位:MVP 无 SSE,记日志即可。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + logger.info("[gen] %s %s/%s %s", stage, i, total, note) + + +def _to_engine_action(t) -> EngineActionType: + """generation.ActionType → 引擎 common.ActionType(按值映射)。 + + walk/idle/attack/jump/custom 均支持视频路线;未覆盖的类型明确报错。 + """ + try: + return EngineActionType(t.value) + except ValueError as e: + raise ValueError(f"动作类型 {t.value!r} 暂不支持视频生成路线") from e + + +class ActionTaskExecutor: + """把一个 PENDING 动作任务跑成 COMPLETED/FAILED。""" + + def __init__( + self, + *, + generator: CharacterGeneratorPort | None = None, + upload: Callable[[bytes], str] | None = None, + fetch_master: Callable[[CharacterActionInput], bytes] | None = None, + fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None, + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._generator = generator # None → 懒加载真实装配 + self._upload = upload # None → 真实对象存储上传 + self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] + self._fetch_constraints = fetch_constraints # None → 查 project 全局约束 + self._session_factory = session_factory # None → SessionLocal + + def run_action_task( + self, + task_id: int, + input: CharacterActionInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + """跑一个动作任务;异常兜底为 FAILED,不抛。 + + 先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session`` + 缺省时自开一个(后台场景);测试可传入自己的 session。 + """ + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + + cons = (self._fetch_constraints or _load_constraints)(session, project_id) + result = self._produce_action(input, cons) + task_repo.update_result(session, task_id, _ACTION_RESULT, result) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常 + logger.exception("动作任务 %s 失败", task_id) + task_repo.update_status( + session, task_id, TaskStatus.FAILED, error_message=str(exc), + ) + if own: + session.commit() + finally: + if own: + session.close() + + # -- 内部 -------------------------------------------------------------- + + def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict: + """母版 → ai_engine 出帧 → 按项目尺寸切帧 → 逐帧上传 → 组结果 dict。 + + 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、 + 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向, + 四向/八向为扩展(需多次生成或镜像)。 + """ + if cons.directions > 1: + logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) + master = (self._fetch_master or self._download_master)(input) + # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字 + desc_parts = [input.custom_prompt or ""] + if cons.style: + desc_parts.append(f"Art style: {cons.style}") + card = CharacterCard(name=f"char-{input.character_id}", desc=" ".join(desc_parts)) + engine_action = _to_engine_action(input.action_type) + action = ActionSpec( + action=engine_action, + poses=[""] * input.num_frames, + facing=cons.facing, + stylize=cons.stylize, + # 自定义动作:动作描述进 i2v 提示词;其他动作类型忽略该字段 + action_desc=input.custom_prompt or "" if engine_action is EngineActionType.CUSTOM else "", + ) + progress: ProgressPort = _LogProgress() + generated = self._get_generator().generate(card, action, master, progress) + + upload = self._upload or self._upload_frame + frames = [ + {"index": i, + "image_url": upload(_fit_to(png, cons.sprite_w, cons.sprite_h)), + "duration_ms": dur} + for i, (png, dur) in enumerate(zip(generated.frames, generated.durations)) + ] + return {"type": "character_action", "action_type": input.action_type.value, "frames": frames} + + def _get_generator(self) -> CharacterGeneratorPort: + """懒装配真实 CharacterGenerator(视频路线 + 桩路线)。""" + if self._generator is None: + from windup_ai_engine.impl import CharacterGenerator + from windup_ai_engine.strategy.concrete import ( + PerFrameStrategy, + ProcIdleStrategy, + VideoFrameStrategy, + ) + from windup_common.models import GenRoute + from windup_framework.providers import ( + OnnxU2NetMatteProvider, + SufyImageProvider, + SufyVideoProvider, + ) + + matte = OnnxU2NetMatteProvider() + video = SufyVideoProvider() + image = SufyImageProvider() + self._generator = CharacterGenerator({ + GenRoute.VIDEO_I2V: VideoFrameStrategy(video, matte), + GenRoute.PER_FRAME: PerFrameStrategy(image, matte), + GenRoute.PROC_IDLE: ProcIdleStrategy(image, matte), + }) + return self._generator + + def _download_master(self, input: CharacterActionInput) -> bytes: + if not input.reference_image_urls: + raise ValueError("缺少母版:reference_image_urls 为空") + resp = httpx.get(input.reference_image_urls[0], timeout=30.0) + resp.raise_for_status() + return resp.content + + def _upload_frame(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="frame.png", + content_type="image/png", + size=len(png), + category=MediaCategory.ACTION_FRAME, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +_IMAGE_RESULT = "character_image" # task_repo._deserialize_result 按此标签反序列化 + + +class ImageTaskExecutor: + """跑角色图片生成任务:参考图 + prompt → 图生图 → 上传 → 回写 image_url。""" + + def __init__( + self, + *, + image=None, # None → 懒加载 SufyImageProvider + upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 + fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._image = image + self._upload = upload + self._fetch_ref = fetch_ref + self._session_factory = session_factory + + def run_image_task( + self, + task_id: int, + input: CharacterImageInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + cons = _load_constraints(session, project_id) # 角色图也受项目约束 + urls = self._produce_image(input, cons) + task_repo.update_result(session, task_id, _IMAGE_RESULT, { + "type": "character_image", + "image_urls": urls, + }) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底 + logger.exception("图片任务 %s 失败", task_id) + task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc)) + if own: + session.commit() + finally: + if own: + session.close() + + def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]: + """根据项目约束决定生成模式,返回 URL 列表。 + + 模式判断: + - 项目有 sprite_sample_url → **图生图**: 风格参考图 + 提示词 + - 项目无 sprite_sample_url → **文生图**: 纯提示词 + 用户传入的 reference_image_url 始终作为角色一致性参考(可选)。 + """ + fetch = self._fetch_ref or self._download + refs: list[bytes] = [] + has_style_ref = False + + # 1. 角色参考图(用户传入,可选,做角色一致性约束) + char_url = (input.reference_image_url or "").strip() + if char_url and char_url.lower() not in ("null", "none", ""): + refs.append(fetch(char_url)) + + # 2. 风格参考图(项目级,有 sprite_sample_url 时走图生图模式) + style_url = (cons.sprite_sample_url or "").strip() + if style_url and style_url.lower() not in ("null", "none", ""): + try: + refs.append(fetch(style_url)) + has_style_ref = True + except Exception: + pass # 风格参考图下载失败不阻断 + + # 3. 构建提示词 + base = input.prompt or "Clean full-body character reference of the figure in the image." + parts = [base, f"{cons.view}, full body head to feet, centered."] + if cons.style: + parts.append(f"Art style: {cons.style}.") + parts.append("Plain light-gray background, no shadow.") + + # 图生图模式:明确标注两张图的各自用途 + if has_style_ref: + prefix = ( + "This is an image-to-image task. " + "The first image is the CHARACTER reference — preserve its identity. " + "The second image is the STYLE reference — follow its art style, " + "color palette, and rendering technique. " + ) + parts.insert(0, prefix) + + prompt = " ".join(parts) + + image_gen = self._get_image() + upload = self._upload or self._upload_image + urls: list[str] = [] + for _ in range(max(1, input.num_images)): + img = image_gen.gen_image(prompt, refs) + urls.append(upload(img)) + return urls + + def _get_image(self): + if self._image is None: + from windup_framework.providers import SufyImageProvider + + self._image = SufyImageProvider() + return self._image + + def _download(self, url: str) -> bytes: + resp = httpx.get(url, timeout=30.0) + resp.raise_for_status() + return resp.content + + def _upload_image(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="character.png", content_type="image/png", + size=len(png), category=MediaCategory.REFERENCE_IMAGE, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +# 默认执行器(真实依赖);bootstrap 取 run_action_task / run_image_task 注入 app.state +executor = ActionTaskExecutor() +run_action_task = executor.run_action_task +image_executor = ImageTaskExecutor() +run_image_task = image_executor.run_image_task diff --git a/backend/packages/app/src/windup_app/server/generation/interface.py b/backend/packages/app/src/windup_app/server/generation/interface.py index b43bace3..409b9e00 100644 --- a/backend/packages/app/src/windup_app/server/generation/interface.py +++ b/backend/packages/app/src/windup_app/server/generation/interface.py @@ -7,21 +7,24 @@ -------- 1. 前端调用 ``generate_character_image`` / ``generate_character_action`` 提交任务, 拿到 ``task_id``。 -2. 前端通过 SSE 订阅任务状态变更,无需轮询。 - web 层提供 ``GET /generation/tasks/{task_id}/stream`` 端点, - 服务端在任务状态变化时推送 ``task_update`` 事件。 - 事件 payload 包含 ``task_id`` / ``task_type`` / ``status``, - 完成时附带 ``result``,失败时附带 ``error_message``。 +2. 前端通过 SSE 订阅状态,刷新恢复时使用 ``get_task`` 查询当前快照。 3. 前端从 ``task.status`` 判断完成,从 ``result`` 取出出参,回填 character 模块: .. code-block:: text CharacterImageOutput.image_url → Character.reference_image_url CharacterActionOutput.frames[] → character_data.outfits[].actions[].frames[] + +约定 +---- +- session-per-call: ``session`` 由调用方(FastAPI 的 ``get_session`` 依赖)按请求传入。 +- 具体实现保持无状态,可作为模块级单例。 """ from abc import ABC, abstractmethod +from sqlalchemy.orm import Session + from windup_app.server.generation.model import ( CharacterActionInput, CharacterImageInput, @@ -35,7 +38,9 @@ class GenerationService(ABC): # -- 任务提交 ------------------------------------------------------------ @abstractmethod - def generate_character_image(self, input: CharacterImageInput) -> GenerationTask: + def generate_character_image( + self, session: Session, *, user_id: int, input: CharacterImageInput, + ) -> GenerationTask: """提交角色图片生成任务。 入参包含参考图 URL 和 prompt 等参数;出参为 ``CharacterImageOutput``, @@ -43,7 +48,9 @@ def generate_character_image(self, input: CharacterImageInput) -> GenerationTask """ @abstractmethod - def generate_character_action(self, input: CharacterActionInput) -> GenerationTask: + def generate_character_action( + self, session: Session, *, user_id: int, input: CharacterActionInput, + ) -> GenerationTask: """提交角色动作生成任务。 入参包含角色 ID、动作类型和参考素材;出参为 ``CharacterActionOutput``, @@ -53,7 +60,9 @@ def generate_character_action(self, input: CharacterActionInput) -> GenerationTa # -- 查询 ---------------------------------------------------------------- @abstractmethod - def get_task(self, project_id: int, task_id: int) -> GenerationTask | None: + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: """查询任务状态与结果。 返回完整的 ``GenerationTask``,前端根据 ``status`` 判断是否完成, diff --git a/backend/packages/app/src/windup_app/server/generation/model.py b/backend/packages/app/src/windup_app/server/generation/model.py index 36fdab58..2449d039 100644 --- a/backend/packages/app/src/windup_app/server/generation/model.py +++ b/backend/packages/app/src/windup_app/server/generation/model.py @@ -9,6 +9,12 @@ from datetime import datetime, timezone from enum import StrEnum +from sqlalchemy import BigInteger, DateTime, Integer, JSON, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + # -- 枚举 ---------------------------------------------------------------- @@ -16,8 +22,8 @@ class GenerationType(StrEnum): """生成任务类型——每新增一种生成能力,在此加一个成员。""" - CHARACTER_IMAGE = "character_image" # 角色参考图 - CHARACTER_ACTION = "character_action" # 角色动作帧序列 + CHARACTER_IMAGE = "character_image" # 角色参考图 + CHARACTER_ACTION = "character_action" # 角色动作帧序列 class ActionType(StrEnum): @@ -25,6 +31,7 @@ class ActionType(StrEnum): WALK = "walk" IDLE = "idle" + JUMP = "jump" ATTACK = "attack" CUSTOM = "custom" @@ -41,6 +48,9 @@ class TaskStatus(StrEnum): # -- 入参 ---------------------------------------------------------------- +DEFAULT_ACTION_FRAME_COUNT = 32 + + @dataclass class CharacterImageInput: """角色图片生成入参。""" @@ -62,7 +72,7 @@ class CharacterActionInput: custom_prompt: str | None = None reference_video_url: str | None = None reference_image_urls: list[str] = field(default_factory=list) - num_frames: int = 16 + num_frames: int = DEFAULT_ACTION_FRAME_COUNT # -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------ @@ -124,3 +134,57 @@ class GenerationTask: @property def is_terminal(self) -> bool: return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED) + + +# -- ORM ----------------------------------------------------------------- + + +class GenerationTaskRecord(Base): + """生成任务持久化记录。 + + ``input_payload`` 和 ``result`` 以 JSON 存储;``result_type`` 标识 + ``result`` 的具体类型,读出后按类型反序列化为对应 dataclass。 + """ + + __tablename__ = "windup_generation_task" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + task_type: Mapped[str] = mapped_column( + Text, + nullable=False, + default=GenerationType.CHARACTER_IMAGE.value, + ) + status: Mapped[str] = mapped_column( + Text, + nullable=False, + default=TaskStatus.PENDING.value, + ) + input_payload: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) + result_type: Mapped[str | None] = mapped_column(Text, nullable=True) + result: Mapped[dict | None] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=True, + ) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/generation/service.py b/backend/packages/app/src/windup_app/server/generation/service.py new file mode 100644 index 00000000..2be42941 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/generation/service.py @@ -0,0 +1,76 @@ +"""生成任务领域服务(提交 + 查询)。 + +:class:`AiGenerationService` 只负责**建任务记录 + 查任务**——web 层依赖本模块。 +实际 AI 生成(调 ai_engine)在 :mod:`.executor` 后台跑,本模块**不碰 ai_engine**, +以满足"入口层(web/worker)不经 ai_engine 直连"的分层门禁(web → service 不得牵出 ai_engine)。 + +无状态:``session`` 由调用方按请求传入,本对象作模块级单例(:data:`service`)。 +""" + +from __future__ import annotations + +import dataclasses +from datetime import datetime, timedelta, timezone + +from sqlalchemy.orm import Session + +from windup_app.server.generation import task_repo +from windup_app.server.generation.interface import GenerationService +from windup_app.server.generation.model import ( + CharacterActionInput, + CharacterImageInput, + GenerationTask, + GenerationType, + TaskStatus, +) + + +_STALE_TASK_AGE = timedelta(minutes=15) +_STALE_TASK_MESSAGE = "生成任务长时间无进展,后台执行可能已中断,请重试" + + +class AiGenerationService(GenerationService): + """生成任务服务:提交(建 PENDING 记录)+ 查询。生成执行在 executor 后台。""" + + def generate_character_image( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterImageInput, + ) -> GenerationTask: + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_IMAGE, + input_payload=dataclasses.asdict(input), + ) + + def generate_character_action( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterActionInput, + ) -> GenerationTask: + """建动作生成任务(PENDING)并返回;实际生成由 executor 后台跑,前端轮询 get_task。""" + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_ACTION, + input_payload=dataclasses.asdict(input), + ) + + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: + task = task_repo.get_task(session, task_id) + if task is None or task.is_terminal: + return task + updated_at = task.update_at + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) - updated_at < _STALE_TASK_AGE: + return task + task_repo.update_status( + session, + task_id, + TaskStatus.FAILED, + error_message=_STALE_TASK_MESSAGE, + ) + return task_repo.get_task(session, task_id) + + +service = AiGenerationService() diff --git a/backend/packages/app/src/windup_app/server/generation/task_repo.py b/backend/packages/app/src/windup_app/server/generation/task_repo.py new file mode 100644 index 00000000..e48670e4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/generation/task_repo.py @@ -0,0 +1,158 @@ +"""生成任务数据访问层。 + +纯 CRUD 操作,不含业务逻辑。所有函数接收 ``session: Session``, +由调用方(FastAPI ``get_session`` 依赖)管理事务边界——本模块只 +``flush`` 不 ``commit``。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.generation.model import ( + CharacterActionOutput, + CharacterImageOutput, + GenerationTask, + GenerationTaskRecord, + GenerationType, + TaskStatus, +) + + +# ── 写入 ───────────────────────────────────────────────────────────────── + + +def create_task( + session: Session, + *, + user_id: int, + project_id: int | None, + task_type: GenerationType, + input_payload: dict, +) -> GenerationTask: + """创建生成任务记录,返回领域对象。""" + record = GenerationTaskRecord( + user_id=user_id, + project_id=project_id, + task_type=task_type.value, + status=TaskStatus.PENDING.value, + input_payload=input_payload, + ) + session.add(record) + session.flush() + return _record_to_domain(record) + + +def update_status( + session: Session, + task_id: int, + status: TaskStatus, + *, + error_message: str | None = None, +) -> None: + """更新任务状态(可选附带错误信息)。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.status = status.value + record.error_message = error_message + record.update_at = datetime.now(timezone.utc) + session.flush() + + +def update_result( + session: Session, + task_id: int, + result_type: str, + result: dict, +) -> None: + """写入任务结果。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.result_type = result_type + record.result = result + record.status = TaskStatus.COMPLETED.value + record.update_at = datetime.now(timezone.utc) + session.flush() + + +# ── 读取 ───────────────────────────────────────────────────────────────── + + +def get_task(session: Session, task_id: int) -> GenerationTask | None: + """按 task_id 查询任务。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return None + return _record_to_domain(record) + + +def get_task_by_user( + session: Session, + user_id: int, + task_id: int, +) -> GenerationTask | None: + """按 user_id + task_id 查询(校验归属)。""" + stmt = select(GenerationTaskRecord).where( + GenerationTaskRecord.id == task_id, + GenerationTaskRecord.user_id == user_id, + ) + record = session.scalar(stmt) + if record is None: + return None + return _record_to_domain(record) + + +# ── 转换 ───────────────────────────────────────────────────────────────── + + +def _record_to_domain(record: GenerationTaskRecord) -> GenerationTask: + """ORM 记录 → 领域 dataclass。""" + result = _deserialize_result(record.result_type, record.result) + return GenerationTask( + id=record.id, + user_id=record.user_id, + project_id=record.project_id, + task_type=GenerationType(record.task_type), + status=TaskStatus(record.status), + input_payload=record.input_payload, + result=result, + error_message=record.error_message, + create_at=record.create_at, + update_at=record.update_at, + ) + + +def _deserialize_result( + result_type: str | None, + raw: dict | None, +) -> CharacterImageOutput | CharacterActionOutput | None: + """根据 ``result_type`` 将 JSON dict 反序列化为对应的 dataclass。""" + if raw is None or result_type is None: + return None + if result_type == "character_image": + return CharacterImageOutput( + type=raw.get("type", "character_image"), + image_urls=raw.get("image_urls", []), + ) + if result_type == "character_action": + from windup_app.server.generation.model import CharacterActionFrame + + frames = [ + CharacterActionFrame( + index=f["index"], + image_url=f["image_url"], + duration_ms=f.get("duration_ms"), + ) + for f in raw.get("frames", []) + ] + return CharacterActionOutput( + type=raw.get("type", "character_action"), + action_type=raw.get("action_type", ""), + frames=frames, + ) + return None diff --git a/backend/packages/app/src/windup_app/server/media/interface.py b/backend/packages/app/src/windup_app/server/media/interface.py index 91d4ca7a..20f9741d 100644 --- a/backend/packages/app/src/windup_app/server/media/interface.py +++ b/backend/packages/app/src/windup_app/server/media/interface.py @@ -6,7 +6,7 @@ class MediaService(ABC): - """文件上传用例的抽象边界。""" + """文件上传 / 删除用例的抽象边界。""" @abstractmethod def upload( @@ -15,3 +15,7 @@ def upload( metadata: MediaUploadInput, ) -> MediaUploadResult: """上传文件到对象存储并返回可回填业务数据的 URL。""" + + @abstractmethod + def delete(self, object_key: str) -> None: + """从对象存储删除指定 key。""" diff --git a/backend/packages/app/src/windup_app/server/media/service.py b/backend/packages/app/src/windup_app/server/media/service.py index daf66a2f..c6f95ffd 100644 --- a/backend/packages/app/src/windup_app/server/media/service.py +++ b/backend/packages/app/src/windup_app/server/media/service.py @@ -1,4 +1,4 @@ -"""媒体上传服务——七牛 Kodo 对象存储实现。""" +"""媒体上传 / 删除服务——七牛 Kodo 对象存储实现。""" from __future__ import annotations @@ -11,7 +11,7 @@ class ObjectStorageMediaService(MediaService): - """通过七牛 Kodo 对象存储上传媒体文件。 + """通过七牛 Kodo 对象存储上传 / 删除媒体文件。 配置来自 ``windup_framework.config.storage.settings`` (环境变量前缀 ``QINIU_``)。 @@ -22,19 +22,14 @@ def upload( data: bytes, metadata: MediaUploadInput, ) -> MediaUploadResult: + from qiniu import Auth, put_data + suffix = _file_suffix(metadata.filename) object_key = f"media/{metadata.category}/{uuid4().hex}{suffix}" - from qiniu import Auth, put_data - auth = Auth(storage_settings.access_key, storage_settings.secret_key) token = auth.upload_token(storage_settings.bucket_name, object_key) - ret, resp = put_data( - token, - object_key, - data, - mime_type=metadata.content_type, - ) + ret, resp = put_data(token, object_key, data, mime_type=metadata.content_type) if resp.status_code != 200 or ret is None: msg = f"七牛上传失败: status={resp.status_code}, body={resp.text}" raise RuntimeError(msg) @@ -48,6 +43,16 @@ def upload( size=metadata.size, ) + def delete(self, object_key: str) -> None: + from qiniu import Auth, BucketManager + + auth = Auth(storage_settings.access_key, storage_settings.secret_key) + manager = BucketManager(auth) + ret, resp = manager.delete(storage_settings.bucket_name, object_key) + if resp.status_code != 200: + msg = f"七牛删除失败: status={resp.status_code}, body={resp.text}" + raise RuntimeError(msg) + def _file_suffix(filename: str) -> str: """仅保留原始文件名后缀,避免把用户文件名写入对象 key。""" diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py b/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py new file mode 100644 index 00000000..8c7ad072 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/__init__.py @@ -0,0 +1 @@ +"""Playtest 核验记录领域。""" diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py b/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py new file mode 100644 index 00000000..92ad3422 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/interface.py @@ -0,0 +1,34 @@ +"""Playtest 核验记录服务接口。""" + +from abc import ABC, abstractmethod + +from sqlalchemy.orm import Session + +from windup_app.server.playtest_inspection.model import PlaytestInspection + + +class PlaytestInspectionService(ABC): + """读取和保存动作当前核验结论的边界。""" + + @abstractmethod + def get_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + ) -> PlaytestInspection | None: + """按动作定位当前核验结论。""" + + @abstractmethod + def save_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + status: str, + ) -> PlaytestInspection: + """新增或覆盖动作当前核验结论。""" diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/model.py b/backend/packages/app/src/windup_app/server/playtest_inspection/model.py new file mode 100644 index 00000000..ce6dcfd0 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/model.py @@ -0,0 +1,43 @@ +"""Playtest 核验记录 ORM 模型。""" + +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + + +class PlaytestInspection(Base): + """某个角色动作当前最新的 Playtest 核验结论。""" + + __tablename__ = "windup_playtest_inspection" + __table_args__ = ( + UniqueConstraint( + "character_id", + "outfit_id", + "action_id", + name="uq_playtest_inspection_target", + ), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + character_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + outfit_id: Mapped[str] = mapped_column(String(128), nullable=False) + action_id: Mapped[str] = mapped_column(String(128), nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/playtest_inspection/service.py b/backend/packages/app/src/windup_app/server/playtest_inspection/service.py new file mode 100644 index 00000000..1e130968 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/playtest_inspection/service.py @@ -0,0 +1,57 @@ +"""Playtest 核验记录的 SQLAlchemy 实现。""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.playtest_inspection.interface import PlaytestInspectionService +from windup_app.server.playtest_inspection.model import PlaytestInspection + + +class SqlAlchemyPlaytestInspectionService(PlaytestInspectionService): + """只保留每个动作最新核验结论,不形成历史版本链。""" + + def get_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + ) -> PlaytestInspection | None: + statement = select(PlaytestInspection).where( + PlaytestInspection.character_id == character_id, + PlaytestInspection.outfit_id == outfit_id, + PlaytestInspection.action_id == action_id, + ) + return session.scalar(statement) + + def save_inspection( + self, + session: Session, + *, + character_id: int, + outfit_id: str, + action_id: str, + status: str, + ) -> PlaytestInspection: + inspection = self.get_inspection( + session, + character_id=character_id, + outfit_id=outfit_id, + action_id=action_id, + ) + if inspection is None: + inspection = PlaytestInspection( + character_id=character_id, + outfit_id=outfit_id, + action_id=action_id, + status=status, + ) + session.add(inspection) + else: + inspection.status = status + session.flush() + return inspection + + +service = SqlAlchemyPlaytestInspectionService() diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py index f9b89de3..9c5aae27 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -2,10 +2,16 @@ 项目 API 只依赖本模块定义的抽象接口。数据库、缓存或其他具体实现应在 应用装配层继承 :class:`ProjectService` 后通过依赖注入提供。 + +约定为 session-per-call:``session`` 由调用方(FastAPI 的 ``get_session`` 依赖) +按请求传入,具体实现(如 :mod:`windup_app.server.project.service`)保持无状态, +可作为模块级单例。 """ from abc import ABC, abstractmethod +from sqlalchemy.orm import Session + from windup_app.server.project.model import Project @@ -13,23 +19,29 @@ class ProjectService(ABC): """项目 CRUD 用例的抽象边界。""" @abstractmethod - def create_project(self, project: Project) -> Project: - """创建项目。""" + def create_project(self, session: Session, **fields) -> Project: + """创建项目。 + + ``fields`` 为项目字段(对齐 ``ProjectCreate`` 的字段集),由实现组装成 + :class:`Project` 后持久化。 + """ @abstractmethod - def project_name_exists(self, *, user_id: int, project_name: str) -> bool: + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: """判断用户下的项目名称是否已存在。""" @abstractmethod - def get_project(self, project_id: int) -> Project | None: + def get_project(self, session: Session, project_id: int) -> Project | None: """按 ID 查询项目。""" @abstractmethod def list_projects( - self, *, page: int, page_size: int, user_id: int | None = None + self, session: Session, *, page: int, page_size: int, user_id: int | None = None ) -> tuple[list[Project], int]: - """分页查询项目。""" + """分页查询项目,返回 (当前页数据, 总数)。""" @abstractmethod - def delete_project(self, project_id: int) -> bool: - """删除项目并返回是否找到。""" + def delete_project(self, session: Session, project_id: int) -> bool: + """在同一事务中删除项目及其角色和 Playtest 核验记录。""" diff --git a/backend/packages/app/src/windup_app/server/project/model.py b/backend/packages/app/src/windup_app/server/project/model.py index 440cafc5..8fd783f5 100644 --- a/backend/packages/app/src/windup_app/server/project/model.py +++ b/backend/packages/app/src/windup_app/server/project/model.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, DateTime, SmallInteger, String, Text, UniqueConstraint +from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from windup_framework.db import Base @@ -16,10 +16,16 @@ class Project(Base): UniqueConstraint("user_id", "project_name", name="uq_windup_project_user_name"), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + # Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走 + # INTEGER PRIMARY KEY 自增(SQLite 仅对该声明自动分配 rowid)。 + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) workflow_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - project_name: Mapped[str] = mapped_column(String(20), nullable=False) + project_name: Mapped[str] = mapped_column(String(64), nullable=False) character_perspective: Mapped[int] = mapped_column(SmallInteger, nullable=False) directional_movement: Mapped[int] = mapped_column(SmallInteger, nullable=False) sprite_width: Mapped[int] = mapped_column(SmallInteger, nullable=False) diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py new file mode 100644 index 00000000..c51198ab --- /dev/null +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -0,0 +1,76 @@ +"""项目领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyProjectService` 继承 :class:`ProjectService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from windup_app.server.character.model import Character +from windup_app.server.playtest_inspection.model import PlaytestInspection +from windup_app.server.project.interface import ProjectService +from windup_app.server.project.model import Project + + +class SqlAlchemyProjectService(ProjectService): + """基于 SQLAlchemy session 的项目 CRUD 实现。""" + + def create_project(self, session: Session, **fields) -> Project: + project = Project(**fields) + session.add(project) + session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at) + return project + + def project_name_exists( + self, session: Session, *, user_id: int, project_name: str + ) -> bool: + stmt = ( + select(Project.id) + .where(Project.user_id == user_id, Project.project_name == project_name) + .limit(1) + ) + return session.scalar(stmt) is not None + + def get_project(self, session: Session, project_id: int) -> Project | None: + return session.get(Project, project_id) + + def list_projects( + self, session: Session, *, page: int, page_size: int, user_id: int | None = None + ) -> tuple[list[Project], int]: + count_stmt = select(func.count()).select_from(Project) + stmt = select(Project) + if user_id is not None: + count_stmt = count_stmt.where(Project.user_id == user_id) + stmt = stmt.where(Project.user_id == user_id) + total = session.scalar(count_stmt) or 0 + stmt = ( + stmt.order_by(Project.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = list(session.scalars(stmt)) + return items, total + + def delete_project(self, session: Session, project_id: int) -> bool: + project = session.get(Project, project_id) + if project is None: + return False + + character_ids = select(Character.id).where(Character.project_id == project_id) + session.execute( + delete(PlaytestInspection).where( + PlaytestInspection.character_id.in_(character_ids) + ) + ) + session.execute(delete(Character).where(Character.project_id == project_id)) + session.delete(project) + session.flush() + return True + + +service = SqlAlchemyProjectService() diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py new file mode 100644 index 00000000..54488164 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -0,0 +1,173 @@ +"""角色 CRUD API。""" + +import logging + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.config.storage import settings as storage_settings +from windup_framework.db import get_session + +from windup_app.server.character.model import Character, CharacterData +from windup_app.server.character.service import service as character_service +from windup_app.server.media.service import service as media_service + +logger = logging.getLogger("windup.character.api") + +router = APIRouter(prefix="/characters", tags=["characters"]) + + +# ── 请求 / 响应模型 ───────────────────────────────────────────────────────── + + +class CharacterCreate(BaseModel): + """创建角色请求。""" + + project_id: int = Field(gt=0) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData = Field(default_factory=CharacterData) + + +class CharacterUpdate(BaseModel): + """更新角色请求——所有字段可选。""" + + project_id: int | None = Field(default=None, gt=0) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData | None = None + + +class CharacterOut(BaseModel): + """角色响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + description: str | None = None + reference_image_url: str | None = None + character_data: dict + status: int + + +# ── 辅助函数 ───────────────────────────────────────────────────────────────── + + +def _extract_object_keys(character: Character) -> list[str]: + """从角色中提取所有对象存储 key,用于删除时清理资源。 + + URL 格式: ``{download_base}/{object_key}`` + """ + prefix = storage_settings.download_base + "/" + keys: list[str] = [] + + # 参考图 + url = character.reference_image_url + if url and url.startswith(prefix): + keys.append(url[len(prefix) :]) + + # character_data 内的 URL + data = character.character_data or {} + for outfit in data.get("outfits", []): + url = outfit.get("preview_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix) :]) + for action in outfit.get("actions", []): + for frame in action.get("frames", []): + url = frame.get("image_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix) :]) + + return keys + + +# ── 端点 ───────────────────────────────────────────────────────────────────── + + +@router.post("", response_model=Response[CharacterOut]) +def create_character( + body: CharacterCreate, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + character = character_service.create_character( + session, + project_id=body.project_id, + description=body.description, + reference_image_url=body.reference_image_url, + character_data=body.character_data.model_dump(), + ) + return Response.success(CharacterOut.model_validate(character), message="创建成功") + + +@router.get("", response_model=ListResponse[CharacterOut]) +def list_characters( + project_id: int = Query(..., gt=0), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + session: Session = Depends(get_session), +) -> ListResponse[CharacterOut]: + items, total = character_service.list_characters( + session, + project_id=project_id, + page=page, + page_size=page_size, + ) + return ListResponse.success( + [CharacterOut.model_validate(c) for c in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{character_id}", response_model=Response[CharacterOut]) +def get_character( + character_id: int, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character)) + + +@router.patch("/{character_id}", response_model=Response[CharacterOut]) +def update_character( + character_id: int, + body: CharacterUpdate, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + fields = body.model_dump(exclude_unset=True) + character = character_service.update_character(session, character_id, **fields) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character), message="更新成功") + + +@router.delete("/{character_id}", response_model=Response[bool]) +def delete_character( + character_id: int, + session: Session = Depends(get_session), +) -> Response[bool]: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + + # 先提取对象 key,再删 DB 记录 + object_keys = _extract_object_keys(character) + + character_service.delete_character(session, character_id) + + # 清理对象存储——失败只记日志,不回滚 DB + for key in object_keys: + try: + media_service.delete(key) + except Exception: + logger.warning("[WINDUP] 媒体清理失败(已跳过) | key=%s", key, exc_info=True) + + return Response.success(True, message="删除成功") diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index be3a5e90..7fa3037e 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -1,13 +1,14 @@ -"""生成任务 API。 - -契约层:定义前端请求/响应的 Pydantic 模型,与 server 层解耦。 -实际逻辑由 server 层实现,本文件只做参数校验和格式转换。 -""" +"""生成任务 API。""" +import asyncio import dataclasses +import json import logging +import threading +from collections.abc import AsyncIterator from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session @@ -17,9 +18,13 @@ from windup_framework.db import get_session from windup_app.server.generation.model import ( + CharacterActionInput, + CharacterImageInput, ActionType, + DEFAULT_ACTION_FRAME_COUNT, GenerationTask, ) +from windup_app.server.generation.service import service as generation_service logger = logging.getLogger("windup.generation.api") @@ -52,7 +57,7 @@ class CharacterActionGenerateRequest(BaseModel): custom_prompt: str | None = None reference_video_url: str | None = None reference_image_urls: list[str] = Field(default_factory=list) - num_frames: int = 16 + num_frames: int = DEFAULT_ACTION_FRAME_COUNT # ── 响应模型 ───────────────────────────────────────────────────────────────── @@ -93,7 +98,9 @@ def _task_to_out(task: GenerationTask) -> GenerationTaskOut: # ── 端点 ───────────────────────────────────────────────────────────────────── -def _validate_project_size(session: Session, project_id: int | None, width: int, height: int) -> None: +def _validate_project_size( + session: Session, project_id: int | None, width: int, height: int +) -> None: """校验输入尺寸与项目约束是否一致;不一致则抛异常。""" if project_id is None: return @@ -117,8 +124,29 @@ def submit_image_generation( ) -> Response[GenerationTaskOut]: """提交角色图片生成任务:建 PENDING 记录立即返回,实际图生图后台跑。""" _validate_project_size(session, body.project_id, body.width, body.height) - # TODO: service.create_image_task + background_tasks.add_task - raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + input_data = CharacterImageInput( + reference_image_url=body.reference_image_url, + prompt=body.prompt, + negative_prompt=body.negative_prompt, + width=body.width, + height=body.height, + num_images=body.num_images, + ) + task = generation_service.generate_character_image( + session, + user_id=body.user_id, + project_id=body.project_id, + input=input_data, + ) + # 后台执行器使用独立 session;必须先提交,否则它可能读不到刚创建的任务, + # 最终生成成功也无法写回,任务会永久停在 PENDING。 + session.commit() + threading.Thread( + target=request.app.state.run_image_task, + args=(task.id, input_data, body.project_id), + daemon=True, + ).start() + return Response.success(_task_to_out(task), message="任务已提交") @router.post("/action", response_model=Response[GenerationTaskOut]) @@ -128,8 +156,30 @@ def submit_action_generation( session: Session = Depends(get_session), ) -> Response[GenerationTaskOut]: """提交角色动作生成任务:建 PENDING 记录立即返回,实际生成后台跑。""" - # TODO: service.create_action_task + background_tasks.add_task - raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + input_data = CharacterActionInput( + character_id=body.character_id, + action_type=body.action_type, + custom_prompt=body.custom_prompt, + reference_video_url=body.reference_video_url, + reference_image_urls=body.reference_image_urls, + num_frames=body.num_frames, + ) + task = generation_service.generate_character_action( + session, + user_id=body.user_id, + project_id=body.project_id, + input=input_data, + ) + # 同图片任务:先让任务记录对后台 session 可见,再启动生成线程。 + session.commit() + # 后台线程自开 session 跑生成(经项目约束 → ai_engine)。调度器由 bootstrap 注入 + # app.state,web 不静态依赖 ai_engine(满足入口层门禁)。 + threading.Thread( + target=request.app.state.run_action_task, + args=(task.id, input_data, body.project_id), + daemon=True, + ).start() + return Response.success(_task_to_out(task), message="任务已提交") @router.get("/tasks/{task_id}", response_model=Response[GenerationTaskOut]) @@ -139,5 +189,55 @@ def get_task( session: Session = Depends(get_session), ) -> Response[GenerationTaskOut]: """查询生成任务状态与结果。""" - # TODO: service.get_task - raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + task = generation_service.get_task(session, project_id, task_id) + if task is None: + raise BizException("任务不存在", code=BizCode.NOT_FOUND) + return Response.success(_task_to_out(task)) + + +def _task_event(task: GenerationTask) -> str: + """把任务快照编码成前端 GenerationApis 约定的命名 SSE 事件。""" + payload = _task_to_out(task).model_dump() + payload["task_id"] = payload.pop("id") + return f"event: task_update\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + + +async def _task_event_stream( + request: Request, + session: Session, + project_id: int, + task_id: int, +) -> AsyncIterator[str]: + """发送当前任务快照,并持续查询数据库直到任务进入终态。""" + last_update = None + while not await request.is_disconnected(): + # 结束上一轮读取事务,确保能看到后台 session 已提交的新状态。 + session.rollback() + task = generation_service.get_task(session, project_id, task_id) + if task is None: + return + update_key = (task.status, task.update_at, task.error_message) + if update_key != last_update: + yield _task_event(task) + last_update = update_key + if task.status.value in {"completed", "failed"}: + return + await asyncio.sleep(1) + + +@router.get("/tasks/{task_id}/stream") +def stream_task( + task_id: int, + request: Request, + project_id: int = Query(..., gt=0), + session: Session = Depends(get_session), +) -> StreamingResponse: + """订阅生成任务状态;连接建立后先发送当前快照,终态后自动关闭。""" + task = generation_service.get_task(session, project_id, task_id) + if task is None: + raise BizException("任务不存在", code=BizCode.NOT_FOUND) + return StreamingResponse( + _task_event_stream(request, session, project_id, task_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/backend/packages/app/src/windup_app/web/api/playtest_inspection.py b/backend/packages/app/src/windup_app/web/api/playtest_inspection.py new file mode 100644 index 00000000..c2c01ae9 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/playtest_inspection.py @@ -0,0 +1,89 @@ +"""Playtest 核验记录 API。""" + +from datetime import datetime +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import Response +from windup_framework.db import get_session + +from windup_app.server.character.service import service as character_service +from windup_app.server.playtest_inspection.service import service + +router = APIRouter(prefix="/playtest-inspections", tags=["playtest"]) + +InspectionStatus = Literal["passed", "issues_found"] + + +class PlaytestInspectionSave(BaseModel): + """保存动作当前核验结论的请求。""" + + character_id: int = Field(gt=0) + outfit_id: str = Field(min_length=1, max_length=128) + action_id: str = Field(min_length=1, max_length=128) + status: InspectionStatus + + +class PlaytestInspectionOut(PlaytestInspectionSave): + """动作当前核验结论。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + create_at: datetime + update_at: datetime + + +def _require_action( + session: Session, *, character_id: int, outfit_id: str, action_id: str +) -> None: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + + outfits = (character.character_data or {}).get("outfits", []) + outfit = next((item for item in outfits if item.get("id") == outfit_id), None) + if outfit is None: + raise BizException("造型不存在", code=BizCode.NOT_FOUND) + if not any(item.get("id") == action_id for item in outfit.get("actions", [])): + raise BizException("动作不存在", code=BizCode.NOT_FOUND) + + +@router.get("", response_model=Response[PlaytestInspectionOut]) +def get_playtest_inspection( + character_id: int = Query(..., gt=0), + outfit_id: str = Query(..., min_length=1, max_length=128), + action_id: str = Query(..., min_length=1, max_length=128), + session: Session = Depends(get_session), +) -> Response[PlaytestInspectionOut]: + inspection = service.get_inspection( + session, + character_id=character_id, + outfit_id=outfit_id, + action_id=action_id, + ) + if inspection is None: + raise BizException("尚未核验", code=BizCode.NOT_FOUND) + return Response.success(PlaytestInspectionOut.model_validate(inspection)) + + +@router.post("", response_model=Response[PlaytestInspectionOut]) +def save_playtest_inspection( + body: PlaytestInspectionSave, + session: Session = Depends(get_session), +) -> Response[PlaytestInspectionOut]: + _require_action( + session, + character_id=body.character_id, + outfit_id=body.outfit_id, + action_id=body.action_id, + ) + inspection = service.save_inspection(session, **body.model_dump()) + return Response.success( + PlaytestInspectionOut.model_validate(inspection), message="核验已保存" + ) diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py new file mode 100644 index 00000000..723c5b40 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -0,0 +1,107 @@ +"""项目 CRUD API。""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.db import get_session + +from windup_app.server.project.service import service + +logger = logging.getLogger("windup.project.api") + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +class ProjectCreate(BaseModel): + """创建项目请求。""" + + user_id: int = Field(gt=0) + workflow_id: int | None = None + project_name: str = Field(min_length=1, max_length=64) + character_perspective: int = Field(ge=1, le=3) + directional_movement: int = Field(ge=1, le=3) + sprite_width: int = Field(ge=32, le=2048) + sprite_height: int = Field(ge=32, le=2048) + game_style: str | None = None + sprite_sample_url: str | None = None + + +class ProjectOut(ProjectCreate): + """项目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + create_at: datetime + update_at: datetime + + +@router.post("", response_model=Response[ProjectOut]) +def create_project( + body: ProjectCreate, session: Session = Depends(get_session) +) -> Response[ProjectOut]: + if service.project_name_exists( + session, user_id=body.user_id, project_name=body.project_name + ): + logger.warning( + "[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s", + body.user_id, + body.project_name, + ) + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) + try: + project = service.create_project(session, **body.model_dump()) + except IntegrityError: + logger.warning( + "[WINDUP] 创建拒绝-并发冲突 | user_id=%s project_name=%s", + body.user_id, + body.project_name, + ) + session.rollback() + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None + return Response.success(ProjectOut.model_validate(project), message="创建成功") + + +@router.get("", response_model=ListResponse[ProjectOut]) +def list_projects( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user_id: int | None = Query(None, gt=0), + session: Session = Depends(get_session), +) -> ListResponse[ProjectOut]: + projects, total = service.list_projects( + session, page=page, page_size=page_size, user_id=user_id + ) + return ListResponse.success( + [ProjectOut.model_validate(item) for item in projects], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{project_id}", response_model=Response[ProjectOut]) +def get_project( + project_id: int, session: Session = Depends(get_session) +) -> Response[ProjectOut]: + project = service.get_project(session, project_id) + if project is None: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return Response.success(ProjectOut.model_validate(project)) + + +@router.delete("/{project_id}", response_model=Response[bool]) +def delete_project( + project_id: int, session: Session = Depends(get_session) +) -> Response[bool]: + if not service.delete_project(session, project_id): + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return Response.success(True, message="删除成功") diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py new file mode 100644 index 00000000..2cb791b8 --- /dev/null +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -0,0 +1,15 @@ +from windup_common.models.character import ( + ActionSpec, + ActionType, + AssetPackageRef, + CharacterCard, + GenRoute, +) + +__all__ = [ + "ActionType", + "GenRoute", + "CharacterCard", + "ActionSpec", + "AssetPackageRef", +] diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py new file mode 100644 index 00000000..cccda23f --- /dev/null +++ b/backend/packages/common/src/windup_common/models/character.py @@ -0,0 +1,81 @@ +"""共享 DTO —— 跨层契约(common,无内部依赖)。 + +产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举、资产包引用。 +仅定义结构,不含行为。ai_engine / app 均依赖此。 +""" +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class ActionType(str, Enum): + """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。""" + + IDLE = "idle" + WALK = "walk" + RUN = "run" + JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases) + ATTACK = "attack" # slash / thrust / dash 归此 + HIT = "hit" + CUSTOM = "custom" # 提示词驱动的自定义动作(走视频路线,动作描述见 ActionSpec.action_desc) + + +class GenRoute(str, Enum): + """生成路线 —— 实测挣得的分流依据(见 strategy 层 docstring)。""" + + VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿) + PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑) + PROC_IDLE = "proc_idle" # 待机:程序化局部呼吸(Idle-B) + + +class CharacterCard(BaseModel): + """角色卡 —— 一致性主键 + 资产库基础(产品核心实体)。""" + + name: str + desc: str # 身份描述(喂模型锁一致性) + palette: str = "" + view: str = "pseudo-side" # side / topdown / isometric + master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径) + version: str = "v1" + + +class ActionSpec(BaseModel): + """动作规格 —— 帧数 / 帧率 / 循环模式 / 逐帧姿势 / 风格化。""" + + action: ActionType + fps: int = 10 + loop: str = "linear" # none / linear / pingpong + poses: list[str] = Field(default_factory=list) + # 风格化:pixel=像素化(原生像素角色 i2v 后复原像素感);none=保留 i2v 插画质感。 + # 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由 CharacterCard 画风决定。 + stylize: str = "pixel" # pixel / none + pixel_h: int = 100 # 像素化目标高(角色像素行数) + palette_size: int = 32 # 色板色数 + # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective): + # side=横版侧走 / front=俯视·2.5D 朝观者。不一致会让模型靠转身调和图文矛盾。 + facing: str = "side" # side / front + # 自定义动作(custom)的自然语言动作描述,如 "the character is painting on an easel"。 + action_desc: str = "" # 仅 custom 使用;其他动作类型忽略 + + @property + def n_frames(self) -> int: + return len(self.poses) + + +class AssetPackageRef(BaseModel): + """生成产出 —— 引擎可用资产包的存储引用(二进制在对象存储)。""" + + character: str + action: ActionType + sheet_ref: str = "" # sprite sheet 存储 ref + frame_refs: list[str] = Field(default_factory=list) + plist_ref: str = "" # Cocos SpriteFrames + fps: int = 10 + # 引擎侧元数据(业界惯例:位移不烘进像素,交引擎驱动): + # root_motion 逐帧 (dx, dy) 像素位移,y 向上为正;durations 逐帧时长(ms), + # 关键帧(攻击触点 / 跳跃顶点)会加长定格 —— 等时长会让动作发飘、没重量感。 + root_motion: list[tuple[int, int]] = Field(default_factory=list) + durations: list[int] = Field(default_factory=list) + qa: dict = Field(default_factory=dict) diff --git a/backend/packages/framework/pyproject.toml b/backend/packages/framework/pyproject.toml index 17726b58..a4550e9c 100644 --- a/backend/packages/framework/pyproject.toml +++ b/backend/packages/framework/pyproject.toml @@ -9,11 +9,22 @@ dependencies = [ "pydantic-settings>=2.4", "sqlalchemy>=2.0", "psycopg[binary]>=3.2", + "redis>=5.0", "httpx>=0.27", "pyjwt>=2.9", - # 以下两项按选型启用: + # AI 模型适配器(providers/):chat 走 langchain,video/image 走 httpx。 + "langchain-core>=0.3", + "langchain-openai>=0.3", + # 抠图 MatteProvider:onnxruntime 直跑 u2netp(替代 rembg,其 numba 老链在 3.12 无轮子)。 + # 上限 <1.24:onnxruntime 自 1.24 起砍了 macOS Intel(x86_64)轮子;1.23.x 仍覆盖 + # Intel/arm64/Linux + py3.12,保证 Intel Mac 也能装。API 与新版一致,不改抠图代码。 + "numpy>=1.26", + "onnxruntime>=1.17,<1.24", + "pillow>=10.4", + # 对象存储(七牛 Kodo);若换 OSS/S3/MinIO 改 oss2 / boto3 / minio。 + "qiniu>=7.14", + # 以下按选型启用: # "rocketmq-client", # RocketMQ Python 客户端(5.x gRPC 版 / C++ 绑定版二选一) - # "minio", # 对象存储;若用 OSS/S3 换 oss2 / boto3 ] [tool.uv.sources] diff --git a/backend/packages/framework/src/windup_framework/config/__init__.py b/backend/packages/framework/src/windup_framework/config/__init__.py index 2f4cd973..8118ac71 100644 --- a/backend/packages/framework/src/windup_framework/config/__init__.py +++ b/backend/packages/framework/src/windup_framework/config/__init__.py @@ -2,13 +2,16 @@ from windup_framework.config.database import DatabaseSettings, settings from windup_framework.config.provider import AIProviderSettings, settings as provider_settings +from windup_framework.config.redis import RedisSettings, settings as redis_settings from windup_framework.config.storage import StorageSettings, settings as storage_settings __all__ = [ "AIProviderSettings", "DatabaseSettings", + "RedisSettings", "StorageSettings", "provider_settings", + "redis_settings", "settings", "storage_settings", ] diff --git a/backend/packages/framework/src/windup_framework/config/database.py b/backend/packages/framework/src/windup_framework/config/database.py index 6cb3d483..894bf8c1 100644 --- a/backend/packages/framework/src/windup_framework/config/database.py +++ b/backend/packages/framework/src/windup_framework/config/database.py @@ -1,9 +1,10 @@ -"""Postgres 数据库连接配置。 +"""数据库连接配置。 -从环境变量(或 ``.env``)读取,字段前缀 ``POSTGRES_``。 -本地开发默认值对应 Docker 容器 root/admin123@localhost:4000。 +优先使用 SQLite(通过 SQLITE_PATH 环境变量),否则回退到 PostgreSQL。 """ +import os + from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import URL @@ -14,7 +15,6 @@ class DatabaseSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="POSTGRES_", - # 兼容从 backend/ 或项目根运行:../.env 覆盖根目录,.env 覆盖当前目录 env_file=("../.env", ".env"), env_file_encoding="utf-8", extra="ignore", @@ -31,11 +31,14 @@ class DatabaseSettings(BaseSettings): @property def url(self) -> str: - """SQLAlchemy 连接串(psycopg3 驱动)。 + """SQLAlchemy 连接串。 - 用 ``URL.create`` 构造以正确转义密码中的保留字符(``@ : /`` 等), - 再渲染为 str 以保持返回类型契约。 + 若设置 SQLITE_PATH 环境变量则使用 SQLite,否则连接 PostgreSQL。 """ + sqlite_path = os.getenv("SQLITE_PATH") + if sqlite_path: + return f"sqlite:///{sqlite_path}" + return URL.create( drivername="postgresql+psycopg", username=self.user, diff --git a/backend/packages/framework/src/windup_framework/config/redis.py b/backend/packages/framework/src/windup_framework/config/redis.py new file mode 100644 index 00000000..61f8b082 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/redis.py @@ -0,0 +1,20 @@ +"""Redis 连接配置。""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RedisSettings(BaseSettings): + """Redis 预留配置;只有 ``REDIS_ENABLED=true`` 时才允许创建客户端。""" + + model_config = SettingsConfigDict( + env_prefix="REDIS_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + enabled: bool = False + url: str = "redis://127.0.0.1:6379/0" + + +settings = RedisSettings() diff --git a/backend/packages/framework/src/windup_framework/config/storage.py b/backend/packages/framework/src/windup_framework/config/storage.py index 06eff8c0..7f12adf7 100644 --- a/backend/packages/framework/src/windup_framework/config/storage.py +++ b/backend/packages/framework/src/windup_framework/config/storage.py @@ -4,16 +4,22 @@ 本地开发需在 ``.env`` 填入 AccessKey / SecretKey / Bucket / 绑定域名。 """ +from pathlib import Path + +from dotenv import load_dotenv from pydantic_settings import BaseSettings, SettingsConfigDict +# 显式加载项目根目录的 .env,避免 CWD 不同时相对路径找不到文件 +_ROOT_ENV = Path(__file__).resolve().parents[6] / ".env" +load_dotenv(_ROOT_ENV, override=False) + class StorageSettings(BaseSettings): """七牛 Kodo 对象存储配置。""" model_config = SettingsConfigDict( env_prefix="QINIU_", - # 兼容从 backend/ 或项目根运行:../.env 覆盖根目录,.env 覆盖当前目录 - env_file=("../.env", ".env"), + env_file=(_ROOT_ENV, ".env"), env_file_encoding="utf-8", extra="ignore", ) @@ -33,7 +39,11 @@ class StorageSettings(BaseSettings): @property def download_base(self) -> str: """下载 URL 基础域名,去掉末尾 ``/``,客户端拼接 key 即可。""" - return self.bucket_domain.rstrip("/") + domain = self.bucket_domain.rstrip("/") + if domain and not domain.startswith(("http://", "https://")): + # 七牛测试域名 SSL 证书可能不匹配,默认用 http + domain = f"http://{domain}" + return domain settings = StorageSettings() diff --git a/backend/packages/framework/src/windup_framework/db/__init__.py b/backend/packages/framework/src/windup_framework/db/__init__.py index 91f57b23..e745ebdb 100644 --- a/backend/packages/framework/src/windup_framework/db/__init__.py +++ b/backend/packages/framework/src/windup_framework/db/__init__.py @@ -1,6 +1,7 @@ -"""数据库基础设施:ORM 基类、engine、session 工厂。""" +"""数据库基础设施:ORM 基类、engine、session 工厂、Redis 客户端。""" from windup_framework.db.base import Base +from windup_framework.db.redis import get_redis from windup_framework.db.session import SessionLocal, engine, get_session -__all__ = ["Base", "SessionLocal", "engine", "get_session"] +__all__ = ["Base", "SessionLocal", "engine", "get_redis", "get_session"] diff --git a/backend/packages/framework/src/windup_framework/db/redis.py b/backend/packages/framework/src/windup_framework/db/redis.py new file mode 100644 index 00000000..693aa313 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/db/redis.py @@ -0,0 +1,25 @@ +"""按需创建 Redis 客户端;默认配置不会打开 Redis 连接。""" + +import redis + +from windup_framework.config.redis import settings as redis_settings + + +from windup_framework.config.redis import RedisSettings + + +def create_redis_client(settings: RedisSettings) -> redis.Redis | None: + """仅在显式启用时创建客户端;redis-py 会在首次命令时建立连接。""" + + if not settings.enabled: + return None + return redis.Redis.from_url(settings.url, decode_responses=True) + + +_client = create_redis_client(redis_settings) + + +def get_redis() -> redis.Redis | None: + """返回可选 Redis 客户端;默认关闭时返回 ``None``。""" + + return _client diff --git a/backend/packages/framework/src/windup_framework/db/session.py b/backend/packages/framework/src/windup_framework/db/session.py index a63b53bd..0dc70209 100644 --- a/backend/packages/framework/src/windup_framework/db/session.py +++ b/backend/packages/framework/src/windup_framework/db/session.py @@ -5,6 +5,7 @@ - ``get_session``: FastAPI 依赖,每请求一个 session """ +import os from collections.abc import Iterator from sqlalchemy import create_engine @@ -12,12 +13,19 @@ from windup_framework.config.database import settings as db_settings -engine = create_engine( - db_settings.url, - pool_size=db_settings.pool_size, - max_overflow=db_settings.max_overflow, - pool_pre_ping=db_settings.pool_pre_ping, -) +sqlite_path = os.getenv("SQLITE_PATH") +if sqlite_path: + engine = create_engine( + f"sqlite:///{sqlite_path}", + connect_args={"check_same_thread": False}, + ) +else: + engine = create_engine( + db_settings.url, + pool_size=db_settings.pool_size, + max_overflow=db_settings.max_overflow, + pool_pre_ping=db_settings.pool_pre_ping, + ) SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index 3524bbf3..61edb2f6 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -1,8 +1,15 @@ -"""按模型能力划分的 AI Provider 接口。""" +"""按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。""" from windup_framework.config.provider import AIProviderSettings from windup_framework.providers.chat import create_chat_model from windup_framework.providers.image import create_image_client +from windup_framework.providers.interfaces import ( + ImageProvider, + MatteProvider, + VideoProvider, +) +from windup_framework.providers.matte import OnnxU2NetMatteProvider +from windup_framework.providers.sufy import SufyImageProvider, SufyVideoProvider from windup_framework.providers.video import create_video_client __all__ = [ @@ -10,4 +17,12 @@ "create_chat_model", "create_image_client", "create_video_client", + # 能力接口(ai_engine 依赖这些稳定契约) + "ImageProvider", + "VideoProvider", + "MatteProvider", + # 实现 + "SufyVideoProvider", + "SufyImageProvider", + "OnnxU2NetMatteProvider", ] diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py new file mode 100644 index 00000000..697962d5 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py @@ -0,0 +1,34 @@ +"""AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。 + +ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。 +实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测 +到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。 + +本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。 +""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ImageProvider(Protocol): + """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。""" + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... + + +@runtime_checkable +class VideoProvider(Protocol): + """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。""" + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: ... + + +@runtime_checkable +class MatteProvider(Protocol): + """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" + + def cutout(self, frame: bytes) -> bytes: ... diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py new file mode 100644 index 00000000..050997b8 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -0,0 +1,103 @@ +"""主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。 + +为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python +3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认 +``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖, +3.12 干净可装、可进 lock。同模型(u2netp),同质量。 + +模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。 +onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。 +""" +from __future__ import annotations + +import io +import urllib.request +from pathlib import Path + +import numpy as np +from PIL import Image + +from .interfaces import MatteProvider + +# u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。 +_U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx" +_DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx" + +# u2net 预处理常量(与 rembg 一致)。 +_MEAN = (0.485, 0.456, 0.406) +_STD = (0.229, 0.224, 0.225) +_SIZE = (320, 320) + + +class OnnxU2NetMatteProvider(MatteProvider): + """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。""" + + def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None: + self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE + self._model_url = model_url + self._session = None # 惰性 + + def _ensure_model(self) -> Path: + if not self._model_path.exists(): + self._model_path.parent.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(self._model_url, self._model_path) + return self._model_path + + def _get_session(self): + if self._session is None: + try: + import onnxruntime as ort # 惰性:导入慢 + except ImportError: + return None # onnxruntime 不可用(如 macOS x86_64),走 Pillow 兜底 + self._session = ort.InferenceSession( + str(self._ensure_model()), providers=["CPUExecutionProvider"] + ) + return self._session + + def _predict_mask(self, img: Image.Image) -> Image.Image: + """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。""" + im = img.convert("RGB").resize(_SIZE, Image.LANCZOS) + ary = np.array(im).astype(np.float32) + ary = ary / max(float(ary.max()), 1e-6) + tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32) + for c in range(3): + tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c] + tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32) + + session = self._get_session() + pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :] + mi, ma = float(pred.min()), float(pred.max()) + pred = (pred - mi) / max(ma - mi, 1e-6) + mask = (pred.squeeze() * 255).astype(np.uint8) + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) + + def cutout(self, frame: bytes) -> bytes: + img = Image.open(io.BytesIO(frame)).convert("RGBA") + session = self._get_session() + if session is not None: + mask = self._predict_mask(img) + else: + mask = self._fallback_mask(img) + cut = Image.composite(img, Image.new("RGBA", img.size, (0, 0, 0, 0)), mask) + buf = io.BytesIO() + cut.save(buf, "PNG") + return buf.getvalue() + + @staticmethod + def _fallback_mask(img: Image.Image) -> Image.Image: + """Pillow 兜底:取四角主色做 chroma-key 式去背(精度远低于 u2netp,仅开发用)。""" + import numpy as np + + ary = np.array(img.convert("RGB")) + # 取四角 8×8 采样主色 + corners = np.concatenate([ + ary[:8, :8].reshape(-1, 3), + ary[:8, -8:].reshape(-1, 3), + ary[-8:, :8].reshape(-1, 3), + ary[-8:, -8:].reshape(-1, 3), + ]) + bg = corners.mean(axis=0) + diff = np.linalg.norm(ary.astype(float) - bg, axis=2) + # 阈值:距离 < 60 视为背景 + mask = (diff > 60).astype(np.uint8) * 255 + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py new file mode 100644 index 00000000..e7c52126 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -0,0 +1,140 @@ +"""Provider 接口的 SUFY / qnaigc(OpenAI-compatible)同步实现。 + +视频走异步任务协议(2026-07-27 端到端实测): + POST /videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 +key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 +重依赖(rembg)惰性导入,保证模块导入零成本。 +""" +from __future__ import annotations + +import base64 +import io +import time + +import httpx + +from windup_framework.config.provider import AIProviderSettings, settings + +from .interfaces import ImageProvider, VideoProvider + +# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 +_IMAGE_LIST_MODELS = ("kling-video-o1",) +DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" + + +def _first_frame_datauri(frame: bytes, size: str) -> str: + """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。 + + 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64 + 会 VENDOR_FAILED(实测)。 + """ + from PIL import Image + + w, h = (int(x) for x in size.split("x")) + im = Image.open(io.BytesIO(frame)).convert("RGB") + pad = im.getpixel((0, 0)) + fitted = im.copy() + fitted.thumbnail((w, h), Image.LANCZOS) + canvas = Image.new("RGB", (w, h), pad) + canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "JPEG", quality=90) + return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() + + +class SufyVideoProvider(VideoProvider): + """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。""" + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = DEFAULT_VIDEO_MODEL, + mode: str = "std", + poll_interval: float = 60.0, + max_min: int = 30, + ) -> None: + self._cfg = config + self._model = model + self._mode = mode + self._poll = poll_interval + self._max_min = max_min + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: + body: dict = { + "model": self._model, + "prompt": prompt, + "size": size, + "seconds": str(seconds), + "mode": self._mode, + } + if self._model in _IMAGE_LIST_MODELS: + b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] + body["image_list"] = [{"image": b64}] + else: + body["input_reference"] = _first_frame_datauri(first_frame, size) + + with self._client() as client: + job = client.post("/videos", json=body).raise_for_status().json() + jid = job.get("id") + url = None + for _ in range(max(1, int(self._max_min * 60 // self._poll))): + time.sleep(self._poll) + st = client.get(f"/videos/{jid}").raise_for_status().json() + status = st.get("status") + if status == "completed": + vids = (st.get("task_result") or {}).get("videos") or [] + url = vids[0].get("url") if vids else None + break + if status in ("failed", "cancelled"): + raise RuntimeError(f"i2v 失败: {status}") + if not url: + raise RuntimeError("i2v 未取得视频 URL(超时或失败)") + return client.get(url).raise_for_status().content + + +class SufyImageProvider(ImageProvider): + """图像 provider:gemini 系图生图(OpenAI 兼容 ``/chat/completions``,返回 base64 图)。 + + 参考图 + 文字约束 → 生成一张图(角色基准图 CHARACTER_IMAGE / 逐帧图生图)。 + key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 + """ + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = "gemini-2.5-flash-image", + ) -> None: + self._cfg = config + self._model = model + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: + import json + import re + + content: list = [{"type": "text", "text": prompt}] + for r in refs: + b64 = base64.b64encode(r).decode() + content.append( + {"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}} + ) + body = {"model": self._model, "messages": [{"role": "user", "content": content}]} + with httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) as client: + res = client.post(self._cfg.chat_completions_path, json=body).raise_for_status().json() + m = re.search(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})", json.dumps(res)) + if not m: + raise RuntimeError(f"图像响应无有效图: {json.dumps(res)[:200]}") + return base64.b64decode(m.group(1)) diff --git a/backend/scripts/install-redis-wsl.sh b/backend/scripts/install-redis-wsl.sh new file mode 100644 index 00000000..83cbcff3 --- /dev/null +++ b/backend/scripts/install-redis-wsl.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +redis_home="${WINDUP_REDIS_HOME:-$HOME/.local/redis-windup}" +redis_server="$redis_home/usr/bin/redis-server" +redis_cli="$redis_home/usr/bin/redis-cli" +export LD_LIBRARY_PATH="$redis_home/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +if [[ -x "$redis_server" && -x "$redis_cli" ]]; then + "$redis_server" --version + exit 0 +fi + +install_dir="$(mktemp -d)" +cleanup() { + rm -rf -- "$install_dir" +} +trap cleanup EXIT + +cd "$install_dir" +apt download redis-server redis-tools liblzf1 >/dev/null +mkdir -p "$redis_home" +for package in ./*.deb; do + dpkg-deb -x "$package" "$redis_home" +done + +mkdir -p "${WINDUP_REDIS_DATA:-$HOME/.local/share/windup-redis}" +"$redis_server" --version diff --git a/backend/scripts/start-redis-wsl.sh b/backend/scripts/start-redis-wsl.sh new file mode 100644 index 00000000..7be3ee2a --- /dev/null +++ b/backend/scripts/start-redis-wsl.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +redis_home="${WINDUP_REDIS_HOME:-$HOME/.local/redis-windup}" +redis_data="${WINDUP_REDIS_DATA:-$HOME/.local/share/windup-redis}" +redis_server="$redis_home/usr/bin/redis-server" +redis_cli="$redis_home/usr/bin/redis-cli" +export LD_LIBRARY_PATH="$redis_home/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +if [[ "${1:-}" == "--ping" ]]; then + exec "$redis_cli" -h 127.0.0.1 -p 6379 ping +fi + +if [[ ! -x "$redis_server" ]]; then + echo "Redis runtime is missing. Run install-redis-wsl.sh first." >&2 + exit 1 +fi + +if "$redis_cli" -h 127.0.0.1 -p 6379 ping 2>/dev/null | grep -qx PONG; then + echo "Redis is already running on 127.0.0.1:6379." + exit 0 +fi + +mkdir -p "$redis_data" +exec "$redis_server" \ + --bind 127.0.0.1 \ + --port 6379 \ + --protected-mode yes \ + --dir "$redis_data" \ + --appendonly yes \ + --save 60 1 \ + --daemonize no diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..420e742b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,81 @@ +"""共享测试夹具。 + +用 SQLite 内存库(``StaticPool`` 单连接)做隔离,不依赖 Docker Postgres, +CI 友好。每个用例各自独立的 engine,互不污染。``Project`` 表按需创建在测试 +engine 上(不碰全局 Postgres engine)。 +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from windup_app.bootstrap.app import create_app +from windup_app.server.character.model import Character +from windup_app.server.generation.model import GenerationTaskRecord +from windup_app.server.playtest_inspection.model import PlaytestInspection +from windup_app.server.project.model import Project +from windup_framework.db import Base, get_session + + +def _make_engine(): + """单连接内存 SQLite;``check_same_thread=False`` 让 TestClient 线程可共用。""" + return create_engine( + "sqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + + +@pytest.fixture() +def engine(): + """建好 ``windup_project`` 表的内存 engine。""" + engine = _make_engine() + Base.metadata.create_all( + engine, + tables=[ + Project.__table__, + Character.__table__, + GenerationTaskRecord.__table__, + PlaytestInspection.__table__, + ], + ) + yield engine + engine.dispose() + + +@pytest.fixture() +def db_session(engine): + """绑定到测试 engine 的 session,供 service 层单测直接传入。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + session = session_local() + try: + yield session + finally: + session.close() + + +@pytest.fixture() +def client(engine): + """FastAPI TestClient;覆盖 ``get_session`` 指向测试 engine。 + + 不进入 lifespan 上下文(跳过 ``print_banner`` 噪音);启动逻辑无 DB 依赖。 + """ + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + session = session_local() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + yield TestClient(app) + app.dependency_overrides.clear() diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py new file mode 100644 index 00000000..e0465ed2 --- /dev/null +++ b/backend/tests/test_ai_engine_skeleton.py @@ -0,0 +1,121 @@ +"""ai_engine 串联 smoke —— 验证架构串联成立:路由正确 + generate 端到端跑通。 + +策略内部(真实 i2v)用 mock / monkeypatch 顶替(真实生成联网、抽帧要解码 mp4); +本测证明"选路线 → derive → 最后一公里(真实对齐)→ GeneratedAction(帧 + 时长)"这条串联为真。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.ports import GeneratedAction +from windup_ai_engine.strategy import ( + ROUTE_MATRIX, + DerivationStrategy, + VideoFrameStrategy, +) +from windup_common.models import ( + ActionSpec, + ActionType, + CharacterCard, + GenRoute, +) + + +def _tiny_png(color=(200, 60, 60, 255), shift=0) -> bytes: + """一张带主体的小 RGBA PNG(四周留透明边,供真实对齐 / 抠图链处理)。""" + img = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(24 + shift, 40 + shift): + img.putpixel((x, y), color) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _NullProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + pass + + +class _MockWalkStrategy(DerivationStrategy): + """顶替真实 VideoFrameStrategy:返回 N 张真 PNG,让对齐真跑。""" + + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [_tiny_png() for _ in range(action.n_frames)] + + +def _make_generator() -> CharacterGenerator: + return CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()}) + + +def test_route_matrix_is_the_measured_contract(): + # 实测挣得的架构决策:走路/跑/攻击走视频,受击逐帧,待机程序化 + assert ROUTE_MATRIX[ActionType.WALK] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.RUN] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.ATTACK] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.JUMP] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.HIT] is GenRoute.PER_FRAME + assert ROUTE_MATRIX[ActionType.IDLE] is GenRoute.VIDEO_I2V + + +def test_generate_walk_is_wired_end_to_end(): + card = CharacterCard(name="rogue", desc="hooded ranger, dual daggers") + action = ActionSpec(action=ActionType.WALK, poses=["p"] * 8) + out = _make_generator().generate(card, action, master=_tiny_png(), progress=_NullProgress()) + assert isinstance(out, GeneratedAction) + assert len(out.frames) == 8 # 选路线→derive→对齐 全串通 + assert len(out.durations) == 8 # 逐帧时长与帧等长 + assert out.fps == action.fps + assert all(f and f[:8] == b"\x89PNG\r\n\x1a\n" for f in out.frames) # 真 PNG + + +def test_action_spec_stylize_defaults_and_toggle(): + # 像素化是开关(默认 pixel),可关成 none 保留 i2v 画风 + assert ActionSpec(action=ActionType.WALK).stylize == "pixel" + a = ActionSpec(action=ActionType.WALK, stylize="none") + assert a.stylize == "none" + + +def test_video_strategy_derive_runs_offline(monkeypatch): + """真实 VideoFrameStrategy.derive 离线跑通(抽帧被顶替,不解码 mp4 / 不联网)。 + + 证明 derive 的真实链路:i2v → 抽帧 → 抠图 → 选帧 → 出帧,产物是合法 RGBA PNG。 + """ + dense = [Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") for i in range(24)] + monkeypatch.setattr( + "windup_ai_engine.strategy.concrete.extract_all_frames_bytes", + lambda video, cap=150: dense, + ) + + class _StubVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"fake-mp4" + + class _StubMatte: + def cutout(self, frame): # 透传:合成帧已带 alpha + return frame + + strat = VideoFrameStrategy(_StubVideo(), _StubMatte()) + card = CharacterCard(name="knight", desc="plate armor, sword") + action = ActionSpec(action=ActionType.WALK, stylize="none", poses=["p"] * 8) + out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress()) + assert out and all(f[:8] == b"\x89PNG\r\n\x1a\n" for f in out) + + +def test_real_video_strategy_is_registered_for_video_route(): + # 真实 VideoFrameStrategy 可构造且声明视频路线(derive 联网,不在此跑) + class _V: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"" + + class _M: + def cutout(self, frame): + return frame + + strat = VideoFrameStrategy(_V(), _M()) + assert strat.route is GenRoute.VIDEO_I2V diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py new file mode 100644 index 00000000..bbf3eba6 --- /dev/null +++ b/backend/tests/test_character_api.py @@ -0,0 +1,53 @@ +"""角色资产 API 集成测试。""" + + +def _character_payload(project_id: int) -> dict: + return { + "project_id": project_id, + "character_data": { + "version": 1, + "outfits": [ + { + "id": "default", + "name": "默认造型", + "actions": [ + { + "id": "idle", + "type": "idle", + "name": "待机", + "fps": 8, + "frame_count": 1, + "frames": [ + { + "index": 0, + "image_url": "https://example.test/frame.png", + "duration_ms": 125, + "root_motion": {"dx": 2, "dy": 1}, + } + ], + } + ], + } + ], + }, + } + + +def test_update_moves_character_and_renames_nested_assets(client): + created = client.post("/characters", json=_character_payload(1)).json()["data"] + updated_tree = created["character_data"] + updated_tree["outfits"][0]["name"] = "夜巡造型" + updated_tree["outfits"][0]["actions"][0]["name"] = "举起灯笼" + + response = client.patch( + f"/characters/{created['id']}", + json={"project_id": 2, "character_data": updated_tree}, + ) + + assert response.status_code == 200 + data = response.json()["data"] + assert data["project_id"] == 2 + assert data["character_data"]["outfits"][0]["name"] == "夜巡造型" + assert data["character_data"]["outfits"][0]["actions"][0]["name"] == "举起灯笼" + frame = data["character_data"]["outfits"][0]["actions"][0]["frames"][0] + assert frame["root_motion"] == {"dx": 2.0, "dy": 1.0} diff --git a/backend/tests/test_cors.py b/backend/tests/test_cors.py new file mode 100644 index 00000000..6bbc67ec --- /dev/null +++ b/backend/tests/test_cors.py @@ -0,0 +1,21 @@ +"""本地前后端联调的跨域边界。""" + +from fastapi.testclient import TestClient + +from windup_app.bootstrap.app import create_app + + +def test_dev_cors_accepts_vite_on_loopback_address() -> None: + """直接打开 127.0.0.1 的 Vite 页面时也能轮询生成结果。""" + client = TestClient(create_app()) + + response = client.options( + "/projects", + headers={ + "Origin": "http://127.0.0.1:5174", + "Access-Control-Request-Method": "GET", + }, + ) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "http://127.0.0.1:5174" diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py new file mode 100644 index 00000000..b4448110 --- /dev/null +++ b/backend/tests/test_generation_api.py @@ -0,0 +1,273 @@ +"""生成任务 HTTP 边界回归测试。""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from windup_app.bootstrap.app import create_app +from windup_app.server.generation import task_repo +from windup_app.server.generation.model import ( + ActionType, + CharacterActionInput, + GenerationTaskRecord, +) +from windup_app.server.generation.service import AiGenerationService +from windup_framework.db import Base, get_session + + +class _ImmediateThread: + """让测试中的后台任务立即运行,稳定复现提交与执行的先后顺序。""" + + def __init__(self, *, target, args, daemon): + self._target = target + self._args = args + + def start(self): + self._target(*self._args) + + +def _action_payload(): + return { + "user_id": 1, + "project_id": 1, + "character_id": 1, + "action_type": "walk", + "reference_image_urls": ["https://cdn.example.com/master.png"], + "num_frames": 2, + } + + +def _image_payload(): + return { + "user_id": 1, + "project_id": None, + "prompt": "像素角色", + "width": 64, + "height": 64, + "num_images": 1, + } + + +def test_action_request_defaults_to_32_frames(client, monkeypatch): + """调用方省略帧数时,HTTP 边界必须创建一项 32 帧动作任务。""" + captured_inputs = [] + + class _CaptureOnlyThread: + def __init__(self, *, target, args, daemon): + captured_inputs.append(args[1]) + + def start(self): + return None + + monkeypatch.setattr( + "windup_app.web.api.generation.threading.Thread", + _CaptureOnlyThread, + ) + payload = _action_payload() + payload.pop("num_frames") + + response = client.post("/generation/action", json=payload) + + assert response.status_code == 200 + assert response.json()["data"]["input_payload"]["num_frames"] == 32 + assert captured_inputs[0].num_frames == 32 + + +def test_action_task_is_committed_before_background_execution( + tmp_path, + monkeypatch, +): + """后台执行器必须能读到刚提交的任务并写入终态。""" + engine = create_engine(f"sqlite:///{tmp_path / 'generation.db'}") + Base.metadata.create_all(engine) + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + with session_local() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + + def complete_task(task_id, _input, _project_id): + with session_local() as session: + task_repo.update_result( + session, + task_id, + "character_action", + { + "type": "character_action", + "action_type": "walk", + "frames": [ + { + "index": 0, + "image_url": "https://cdn.example.com/frame.png", + "duration_ms": 100, + } + ], + }, + ) + session.commit() + + app.state.run_action_task = complete_task + monkeypatch.setattr( + "windup_app.web.api.generation.threading.Thread", + _ImmediateThread, + ) + + with TestClient(app) as client: + submitted = client.post("/generation/action", json=_action_payload()).json()[ + "data" + ] + task = client.get( + f"/generation/tasks/{submitted['id']}", + params={"project_id": 1}, + ).json()["data"] + + assert task["status"] == "completed" + assert task["result"]["frames"][0]["image_url"].endswith("frame.png") + engine.dispose() + + +def test_image_task_is_committed_before_background_execution(tmp_path, monkeypatch): + """Quick Start 的首段图片任务也必须在后台执行前完成提交。""" + engine = create_engine(f"sqlite:///{tmp_path / 'image-generation.db'}") + Base.metadata.create_all(engine) + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + with session_local() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + + def complete_task(task_id, _input, _project_id): + with session_local() as session: + task_repo.update_result( + session, + task_id, + "character_image", + { + "type": "character_image", + "image_urls": ["https://cdn.example.com/character.png"], + }, + ) + session.commit() + + app.state.run_image_task = complete_task + monkeypatch.setattr( + "windup_app.web.api.generation.threading.Thread", + _ImmediateThread, + ) + + with TestClient(app) as client: + submitted = client.post("/generation/image", json=_image_payload()).json()[ + "data" + ] + task = client.get( + f"/generation/tasks/{submitted['id']}", + params={"project_id": 1}, + ).json()["data"] + + assert task["status"] == "completed" + assert task["result"]["image_urls"] == ["https://cdn.example.com/character.png"] + engine.dispose() + + +def test_completed_task_stream_emits_terminal_snapshot(client, engine): + """晚于任务完成建立 SSE 连接时,也必须立刻收到可恢复的终态快照。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + action_input = CharacterActionInput( + character_id=1, + action_type=ActionType.WALK, + reference_image_urls=["https://cdn.example.com/master.png"], + num_frames=1, + ) + with session_local() as session: + task = AiGenerationService().generate_character_action( + session, + user_id=1, + project_id=1, + input=action_input, + ) + session.commit() + task_repo.update_result( + session, + task.id, + "character_action", + { + "type": "character_action", + "action_type": "walk", + "frames": [ + { + "index": 0, + "image_url": "https://cdn.example.com/frame.png", + "duration_ms": 100, + } + ], + }, + ) + session.commit() + task_id = task.id + + with client.stream( + "GET", + f"/generation/tasks/{task_id}/stream", + params={"project_id": 1}, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + event_lines = [line for line in body.splitlines() if line.startswith("data: ")] + assert len(event_lines) == 1 + payload = json.loads(event_lines[0].removeprefix("data: ")) + assert payload["task_id"] == task_id + assert payload["task_type"] == "character_action" + assert payload["status"] == "completed" + assert payload["result"]["frames"][0]["image_url"].endswith("frame.png") + + +def test_stale_incomplete_task_becomes_retryable_failure(client, engine): + """后台进程丢失的旧任务不能让 Quick Start 永久停在生成中。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + with session_local() as session: + task = AiGenerationService().generate_character_action( + session, + user_id=1, + project_id=1, + input=CharacterActionInput( + character_id=1, + action_type=ActionType.WALK, + reference_image_urls=["https://cdn.example.com/master.png"], + ), + ) + session.commit() + record = session.get(GenerationTaskRecord, task.id) + record.update_at = datetime.now(timezone.utc) - timedelta(minutes=16) + session.commit() + task_id = task.id + + response = client.get( + f"/generation/tasks/{task_id}", + params={"project_id": 1}, + ).json()["data"] + + assert response["status"] == "failed" + assert "请重试" in response["error_message"] diff --git a/backend/tests/test_generation_orchestration.py b/backend/tests/test_generation_orchestration.py new file mode 100644 index 00000000..465e6756 --- /dev/null +++ b/backend/tests/test_generation_orchestration.py @@ -0,0 +1,189 @@ +"""生成任务编排端到端(离线):提交任务 → 后台调 ai_engine 出帧 → 上传 → 写回结果。 + +用内存 sqlite + 真实 GenerationTaskRecord ORM + 真实 AiGenerationService + 真实 +CharacterGenerator(视频 provider / matte / 抽帧全部桩替,不联网、不碰对象存储)。 +证明"任务 → ai_engine → 帧 → COMPLETED"这条链真能跑通。 +""" +from __future__ import annotations + +import io + +import pytest +from PIL import Image +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from windup_framework.db.base import Base +from windup_app.server.project.model import Project # 注册 windup_project 表(create_all 用) +from windup_app.server.generation.model import ( + ActionType, + CharacterActionInput, + CharacterActionOutput, + TaskStatus, +) +from windup_app.server.generation.executor import ActionTaskExecutor +from windup_app.server.generation.service import AiGenerationService +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.strategy.concrete import VideoFrameStrategy +from windup_common.models import GenRoute + + +def _tiny_png(shift: int = 0) -> bytes: + img = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(24 + shift, 40 + shift): + img.putpixel((x, y), (200, 60, 60, 255)) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _StubVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"fake-mp4" + + +class _StubMatte: + def cutout(self, frame): + return frame + + +@pytest.fixture +def session_factory(): + """共享的内存 sqlite(StaticPool 保证多 session 同库),建好任务表。""" + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + return sessionmaker(bind=engine) + + +def _real_offline_generator(monkeypatch) -> CharacterGenerator: + """真实 CharacterGenerator,但抽帧顶替成合成帧(不解码 mp4 / 不联网)。""" + dense = [ + Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") + for i in range(24) + ] + monkeypatch.setattr( + "windup_ai_engine.strategy.concrete.extract_all_frames_bytes", + lambda video, cap=150: dense, + ) + return CharacterGenerator( + {GenRoute.VIDEO_I2V: VideoFrameStrategy(_StubVideo(), _StubMatte())} + ) + + +def test_action_task_runs_end_to_end(session_factory, monkeypatch): + uploaded: list[bytes] = [] + + def _upload(png: bytes) -> str: + uploaded.append(png) + return f"https://cdn.example.com/frame-{len(uploaded)}.png" + + service = AiGenerationService() + executor = ActionTaskExecutor( + generator=_real_offline_generator(monkeypatch), + upload=_upload, + fetch_master=lambda _input: _tiny_png(), + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=6, + ) + + # 1) 提交:建 PENDING 任务 + with session_factory() as s: + task = service.generate_character_action(s, user_id=1, input=action_input) + s.commit() + task_id = task.id + assert task.status is TaskStatus.PENDING + + # 2) 后台跑(自开 session) + executor.run_action_task(task_id, action_input) + + # 3) 轮询:任务 COMPLETED,结果是含 URL 的帧序列 + with session_factory() as s: + done = service.get_task(s, project_id=1, task_id=task_id) + assert done is not None + assert done.status is TaskStatus.COMPLETED + assert isinstance(done.result, CharacterActionOutput) + assert done.result.action_type == "walk" + assert len(done.result.frames) >= 1 + assert uploaded, "应逐帧上传" + for i, frame in enumerate(done.result.frames): + assert frame.index == i + assert frame.image_url.startswith("https://") + assert frame.duration_ms is not None + + +class _SpyGenerator: + """记录传入的 facing,验证项目约束确实喂进了 ai_engine。""" + + def __init__(self) -> None: + self.seen_facing: str | None = None + + def generate(self, card, action, master, progress): + from windup_ai_engine.ports import GeneratedAction + + self.seen_facing = action.facing + return GeneratedAction(frames=[_tiny_png()], durations=[100], fps=10) + + +def test_project_perspective_constrains_facing(session_factory): + # perspective=2 → front(见 executor._PERSPECTIVE_TO_FACING) + with session_factory() as s: + proj = Project( + user_id=1, project_name="p", character_perspective=2, + directional_movement=1, sprite_width=64, sprite_height=64, + ) + s.add(proj) + s.commit() + project_id = proj.id + + spy = _SpyGenerator() + executor = ActionTaskExecutor( + generator=spy, + upload=lambda _png: "https://cdn.example.com/f.png", + fetch_master=lambda _input: _tiny_png(), + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=2, + ) + with session_factory() as s: + task = AiGenerationService().generate_character_action(s, user_id=1, input=action_input) + s.commit() + task_id = task.id + + executor.run_action_task(task_id, action_input, project_id) # 带项目约束 + + assert spy.seen_facing == "front", "项目 perspective 应约束生成朝向" + + +def test_action_task_marks_failed_on_error(session_factory): + def _boom(_input): + raise RuntimeError("母版下载失败") + + service = AiGenerationService() + executor = ActionTaskExecutor( + generator=None, # 不会用到:取母版先炸 + fetch_master=_boom, + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=4, + ) + with session_factory() as s: + task = service.generate_character_action(s, user_id=1, input=action_input) + s.commit() + task_id = task.id + + executor.run_action_task(task_id, action_input) # 不抛,兜底为 FAILED + + with session_factory() as s: + done = service.get_task(s, project_id=1, task_id=task_id) + assert done.status is TaskStatus.FAILED + assert "母版下载失败" in (done.error_message or "") diff --git a/backend/tests/test_loop.py b/backend/tests/test_loop.py new file mode 100644 index 00000000..4d447a9a --- /dev/null +++ b/backend/tests/test_loop.py @@ -0,0 +1,46 @@ +"""循环闭合(周期检测 + 单周期取帧)测试 —— 纯 CV,无需联网。""" + +from PIL import Image + +from windup_ai_engine.slicing import find_period, pick_cycle + + +def _periodic_frames(period: int, cycles: int) -> list[Image.Image]: + """构造已知周期的帧序列:亮度按周期正弦变化(每帧一张纯灰图)。""" + import math + + frames = [] + for i in range(period * cycles): + v = int(128 + 100 * math.sin(2 * math.pi * i / period)) + frames.append(Image.new("RGB", (48, 48), (v, v, v))) + return frames + + +def test_find_period_detects_known_period(): + frames = _periodic_frames(period=20, cycles=5) + p = find_period(frames) + assert abs(p - 20) <= 1 # 检出周期 ≈ 真值 + + +def test_pick_cycle_returns_n_frames(): + frames = _periodic_frames(period=20, cycles=5) + out = pick_cycle(frames, 8) + assert len(out) == 8 + + +def test_pick_cycle_passthrough_when_too_few(): + frames = _periodic_frames(period=4, cycles=1) # 4 帧 < 8 + assert pick_cycle(frames, 8) is frames + + +def test_pick_cycle_closes_the_loop(): + # 取出的一周期,末帧的下一拍应接近首帧(亮度差小) + import numpy as np + + frames = _periodic_frames(period=20, cycles=5) + out = pick_cycle(frames, 8) + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + step = np.abs(np.asarray(out[1].convert("L"), float) - first).mean() + seam = np.abs(last - first).mean() + assert seam <= step * 2 + 5 # 回接缝不显著大于一个正常步幅 diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py new file mode 100644 index 00000000..9c9bd184 --- /dev/null +++ b/backend/tests/test_matte_provider.py @@ -0,0 +1,16 @@ +"""OnnxU2NetMatteProvider 契约测试(不加载模型 / 不联网:构造 + 协议合规)。""" + +from windup_framework.providers import MatteProvider, OnnxU2NetMatteProvider + + +def test_onnx_matte_satisfies_matte_provider_protocol(): + # 运行时可检查协议:有 cutout 即满足 MatteProvider(server/ai_engine 依赖此契约) + provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") + assert isinstance(provider, MatteProvider) + assert callable(provider.cutout) + + +def test_onnx_matte_lazy_no_model_load_on_construct(): + # 构造不触发下载 / 会话创建(惰性),模型缺失也不报错 + provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") + assert provider._session is None diff --git a/backend/tests/test_oneshot.py b/backend/tests/test_oneshot.py new file mode 100644 index 00000000..ea1cde95 --- /dev/null +++ b/backend/tests/test_oneshot.py @@ -0,0 +1,68 @@ +"""一次性动作抽帧(裁动作区间 / 跳跃状态切段)测试 —— 纯 CV,无需联网。""" + +import numpy as np +from PIL import Image + +from windup_ai_engine.slicing import ( + find_motion_span, + foot_line_series, + pick_oneshot, + split_jump_phases, +) + + +def _figure_at(y_bottom: int, size: int = 64, h: int = 20) -> Image.Image: + """在指定底边高度画一个方块"角色"(RGBA,其余透明)。""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + arr = np.asarray(img).copy() + top = max(0, y_bottom - h) + arr[top:y_bottom, size // 2 - 4 : size // 2 + 4] = (200, 60, 60, 255) + return Image.fromarray(arr, "RGBA") + + +def _jump_sequence() -> list[Image.Image]: + """合成跳跃:静止 → 蹲(底边下移)→ 升 → 顶点 → 落 → 静止。""" + ground, low, apex = 50, 52, 30 + ys = [ground] * 3 + [low, low] + [44, 38, apex, apex, 38, 44] + [ground] * 3 + return [_figure_at(y) for y in ys] + + +def test_find_motion_span_trims_static_head_and_tail(): + frames = _jump_sequence() + start, end = find_motion_span(frames) + assert start >= 1 # 前面的静止帧被裁掉 + assert end <= len(frames) - 2 # 后面的静止帧被裁掉 + assert end > start + + +def test_pick_oneshot_returns_n_and_does_not_wrap(): + frames = _jump_sequence() + out = pick_oneshot(frames, 6) + assert len(out) == 6 + # 一次性动作不闭环:首尾姿态应不同(闭环的话会几乎一样) + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + assert np.abs(first - last).mean() >= 0 + + +def test_foot_line_tracks_height(): + frames = _jump_sequence() + y = foot_line_series(frames) + assert y.argmin() in range(6, 10) # 最高点(y 最小)落在顶点附近 + assert y[0] > y.min() # 起始在地面,低于顶点 + + +def test_split_jump_phases_covers_all_frames_in_order(): + frames = _jump_sequence() + phases = split_jump_phases(frames) + assert "apex" in phases + idx = [i for seg in phases.values() for i in seg] + assert sorted(idx) == list(range(len(frames))) # 不重不漏 + # apex 段应在 rise 之后、fall 之前 + if "rise" in phases and "fall" in phases: + assert max(phases["rise"]) < min(phases["apex"]) + assert max(phases["apex"]) < min(phases["fall"]) + + +def test_split_jump_phases_short_input_is_safe(): + assert split_jump_phases([_figure_at(50)] * 3) diff --git a/backend/tests/test_pixelate.py b/backend/tests/test_pixelate.py new file mode 100644 index 00000000..11e5bdae --- /dev/null +++ b/backend/tests/test_pixelate.py @@ -0,0 +1,103 @@ +"""像素化后处理测试(纯 CV,无需联网 / API)。""" + +import numpy as np +from PIL import Image + +from windup_ai_engine.postprocess import ( + pixelate_frames, + sprite_sheet, + to_pixel_art, +) + + +def _synthetic_char(size=256, box=(80, 40, 176, 220)) -> Image.Image: + """透明底上画一个不透明矩形"角色",四周留透明边。""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + arr = np.asarray(img).copy() + x0, y0, x1, y1 = box + arr[y0:y1, x0:x1] = (200, 60, 60, 255) + # 加一点颜色变化,让色板量化有意义 + arr[y0:y1, x0 : (x0 + x1) // 2] = (60, 120, 200, 255) + return Image.fromarray(arr, "RGBA") + + +def test_to_pixel_art_targets_height_and_keeps_ratio(): + src = _synthetic_char() # 主体 96x180 + out = to_pixel_art(src, target_h=60, palette_size=16) + assert out.height == 60 + # 主体宽高比 96/180 → 目标宽 ≈ 60*96/180 = 32 + assert abs(out.width - 32) <= 1 + assert out.mode == "RGBA" + + +def test_to_pixel_art_crops_to_alpha_bbox(): + """输出应裁到主体包围盒:透明边被切掉,首列即主体。""" + out = to_pixel_art(_synthetic_char(), target_h=90, palette_size=16) + alpha = np.asarray(out)[:, :, 3] + assert alpha.max() == 255 # 有实心主体 + # 顶行与左列应落在主体上(已裁边),而非全透明 + assert alpha[0, :].max() > 0 + assert alpha[:, 0].max() > 0 + + +def test_to_pixel_art_reduces_palette(): + out = to_pixel_art(_synthetic_char(), target_h=80, palette_size=8) + rgb = np.asarray(out.convert("RGB")).reshape(-1, 3) + colors = np.unique(rgb, axis=0) + assert len(colors) <= 8 + + +def test_pixelate_frames_uniform_height_packs_to_sheet(): + frames = pixelate_frames([_synthetic_char() for _ in range(4)], target_h=48, palette_size=16) + assert all(f.height == 48 for f in frames) + sheet = sprite_sheet(frames) + assert sheet.height == 48 + assert sheet.width == sum(f.width for f in frames) + + +def test_to_pixel_art_rejects_bad_height(): + import pytest + + with pytest.raises(ValueError): + to_pixel_art(_synthetic_char(), target_h=0) + + +def _pixel_art(block=8, logical_h=20, bg=(255, 255, 255)) -> Image.Image: + """合成像素画:每个逻辑像素放大成 block×block 方块,白底(模拟母版)。""" + colors = [(200, 60, 60), (60, 120, 200), (40, 160, 90)] + small = np.full((logical_h, logical_h // 2, 3), bg, dtype=np.uint8) + for y in range(4, logical_h - 4): + for x in range(2, logical_h // 2 - 2): + small[y, x] = colors[(x + y) % len(colors)] + img = Image.fromarray(small, "RGB").resize( + (small.shape[1] * block, logical_h * block), Image.NEAREST + ) + return img.convert("RGBA") + + +def test_detect_pixel_size_finds_block(): + from windup_ai_engine.postprocess import detect_pixel_size + + assert detect_pixel_size(_pixel_art(block=8)) == 8 + assert detect_pixel_size(_pixel_art(block=12)) == 12 + + +def test_master_pixel_spec_gives_logical_height_and_palette(): + from windup_ai_engine.postprocess import master_pixel_spec + + logical_h, palette = master_pixel_spec(_pixel_art(block=8, logical_h=20)) + assert 10 <= logical_h <= 14 # 主体(去掉白边)约 12 个逻辑像素高 + assert 2 <= len(palette) <= 32 + # 色板不应被白底/抗锯齿近白色占据 + assert not (palette.astype(int).sum(axis=1) > 700).all() + + +def test_palette_lock_restricts_output_colors(): + """锁色板后,输出颜色必须全部来自给定色板(用于消掉压缩灰颗粒)。""" + palette = np.array([[200, 60, 60], [60, 120, 200]], dtype=np.uint8) + noisy = _synthetic_char() + out = to_pixel_art(noisy, target_h=24, palette=palette) + rgb = np.asarray(out.convert("RGB")).reshape(-1, 3) + used = np.unique(rgb, axis=0) + for c in used: + assert (c == palette).all(axis=1).any(), f"{c} 不在色板内" diff --git a/backend/tests/test_playtest_inspection_api.py b/backend/tests/test_playtest_inspection_api.py new file mode 100644 index 00000000..5721499e --- /dev/null +++ b/backend/tests/test_playtest_inspection_api.py @@ -0,0 +1,71 @@ +"""Playtest 核验记录 API 集成测试。""" + + +def _character_payload() -> dict: + return { + "project_id": 1, + "character_data": { + "version": 1, + "outfits": [ + { + "id": "default", + "name": "默认造型", + "actions": [ + { + "id": "idle", + "type": "idle", + "name": "待机", + "fps": 8, + "frame_count": 0, + "frames": [], + } + ], + } + ], + }, + } + + +def test_save_and_read_current_inspection(client): + character = client.post("/characters", json=_character_payload()).json()["data"] + target = { + "character_id": character["id"], + "outfit_id": "default", + "action_id": "idle", + } + + missing = client.get("/playtest-inspections", params=target).json() + assert missing["code"] == 404 + + saved = client.post( + "/playtest-inspections", + json={**target, "status": "passed"}, + ).json() + assert saved["code"] == 200 + assert saved["data"]["status"] == "passed" + + updated = client.post( + "/playtest-inspections", + json={**target, "status": "issues_found"}, + ).json() + assert updated["data"]["id"] == saved["data"]["id"] + assert updated["data"]["status"] == "issues_found" + + loaded = client.get("/playtest-inspections", params=target).json() + assert loaded["data"]["status"] == "issues_found" + + +def test_rejects_an_inspection_for_an_unknown_action(client): + character = client.post("/characters", json=_character_payload()).json()["data"] + response = client.post( + "/playtest-inspections", + json={ + "character_id": character["id"], + "outfit_id": "default", + "action_id": "missing", + "status": "passed", + }, + ) + + assert response.json()["code"] == 404 + assert response.json()["message"] == "动作不存在" diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py new file mode 100644 index 00000000..962482f1 --- /dev/null +++ b/backend/tests/test_project_api.py @@ -0,0 +1,174 @@ +"""项目 CRUD API 集成测试。 + +通过 ``TestClient`` 打全链路:请求 -> 路由 -> service -> SQLite -> 统一响应。 +验证统一响应契约(HTTP 恒 200、code 在 body、``ListResponse`` 分页字段、 +``timestamp`` 默认省略)与 400/404 业务码路径。 +""" + + +def _payload(**overrides): + """构造合法的创建请求体(对齐 ``ProjectCreate``)。""" + base = { + "user_id": 10001, + "project_name": "像素游戏", + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + } + base.update(overrides) + return base + + +# -- POST /projects ---------------------------------------------------------- + + +def test_create_success(client): + resp = client.post("/projects", json=_payload(project_name="新建")) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "创建成功" + assert body["data"]["id"] is not None + assert body["data"]["project_name"] == "新建" + assert body["data"]["create_at"] + assert "timestamp" not in body + + +def test_create_duplicate_name_returns_400(client): + client.post("/projects", json=_payload(project_name="重名")) + resp = client.post("/projects", json=_payload(project_name="重名")) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 400 + assert body["message"] == "项目名称已存在" + assert body["data"] is None + + +def test_create_validation_error_returns_400(client): + resp = client.post("/projects", json=_payload(project_name="x" * 65)) + + assert resp.status_code == 200 + assert resp.json()["code"] == 400 + + +# -- GET /projects/{id} ------------------------------------------------------ + + +def test_get_success(client): + created = client.post("/projects", json=_payload(project_name="详情")).json()[ + "data" + ] + resp = client.get(f"/projects/{created['id']}") + + assert resp.json()["code"] == 200 + assert resp.json()["data"]["project_name"] == "详情" + + +def test_get_not_found_returns_404(client): + resp = client.get("/projects/99999") + + body = resp.json() + assert body["code"] == 404 + assert body["message"] == "项目不存在" + assert body["data"] is None + + +# -- GET /projects ----------------------------------------------------------- + + +def test_list_empty(client): + resp = client.get("/projects") + + body = resp.json() + assert body["code"] == 200 + assert body["data"] == [] + assert body["total"] == 0 + assert body["page"] == 1 + assert body["page_size"] == 20 + + +def test_list_paginates_and_filters(client): + for i in range(3): + client.post("/projects", json=_payload(user_id=10001, project_name=f"a{i}")) + client.post("/projects", json=_payload(user_id=20002, project_name="other")) + + resp = client.get("/projects", params={"page": 1, "page_size": 2, "user_id": 10001}) + + body = resp.json() + assert body["total"] == 3 + assert len(body["data"]) == 2 + assert [item["project_name"] for item in body["data"]] == ["a2", "a1"] + assert all(item["user_id"] == 10001 for item in body["data"]) + + +# -- DELETE /projects/{id} --------------------------------------------------- + + +def test_delete_success(client): + created = client.post("/projects", json=_payload(project_name="删除")).json()[ + "data" + ] + resp = client.delete(f"/projects/{created['id']}") + + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "删除成功" + assert client.get(f"/projects/{created['id']}").json()["code"] == 404 + + +def test_delete_not_found_returns_404(client): + resp = client.delete("/projects/99999") + + assert resp.json()["code"] == 404 + + +def test_delete_cascades_characters_and_playtest_inspections(client): + project = client.post("/projects", json=_payload(project_name="级联删除")).json()[ + "data" + ] + character = client.post( + "/characters", + json={ + "project_id": project["id"], + "character_data": { + "version": 1, + "outfits": [ + { + "id": "default", + "name": "默认造型", + "actions": [ + { + "id": "idle", + "type": "idle", + "name": "待机", + "fps": 8, + "frame_count": 0, + "frames": [], + } + ], + } + ], + }, + }, + ).json()["data"] + inspection_target = { + "character_id": character["id"], + "outfit_id": "default", + "action_id": "idle", + } + client.post( + "/playtest-inspections", + json={**inspection_target, "status": "passed"}, + ) + + response = client.delete(f"/projects/{project['id']}") + + assert response.json()["data"] is True + assert client.get(f"/characters/{character['id']}").json()["code"] == 404 + assert ( + client.get("/playtest-inspections", params=inspection_target).json()["code"] + == 404 + ) diff --git a/backend/tests/test_project_service.py b/backend/tests/test_project_service.py new file mode 100644 index 00000000..72138b4b --- /dev/null +++ b/backend/tests/test_project_service.py @@ -0,0 +1,185 @@ +"""``SqlAlchemyProjectService`` 单元测试。 + +直接把测试 session 传入 service 方法,验证 CRUD 语义:生成字段、唯一约束、 +分页、按 id 倒序、按 user 过滤、删除幂等。 +""" + +import pytest +from sqlalchemy.exc import IntegrityError + +from windup_app.server.character.model import Character +from windup_app.server.playtest_inspection.model import PlaytestInspection +from windup_app.server.project.model import Project +from windup_app.server.project.service import service + + +def _fields(**overrides): + """构造合法的项目字段(对齐 ``ProjectCreate`` 字段集)。""" + base = { + "user_id": 10001, + "project_name": "像素游戏", + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + } + base.update(overrides) + return base + + +# -- create ------------------------------------------------------------------ + + +def test_create_returns_project_with_generated_fields(db_session): + project = service.create_project(db_session, **_fields(project_name="新建")) + + assert project.id is not None + assert project.create_at is not None + assert project.update_at is not None + assert project.user_id == 10001 + assert project.project_name == "新建" + + +def test_create_persists_and_is_queryable(db_session): + created = service.create_project(db_session, **_fields(project_name="持久化")) + + fetched = db_session.get(Project, created.id) + assert fetched is not None + assert fetched.project_name == "持久化" + + +def test_create_duplicate_name_raises_integrity_error(db_session): + service.create_project(db_session, **_fields(project_name="重名")) + + with pytest.raises(IntegrityError): + service.create_project(db_session, **_fields(project_name="重名")) + + +# -- project_name_exists ----------------------------------------------------- + + +def test_name_exists_false_when_absent(db_session): + assert ( + service.project_name_exists(db_session, user_id=10001, project_name="无") + is False + ) + + +def test_name_exists_true_when_present(db_session): + service.create_project(db_session, **_fields(user_id=10001, project_name="已存在")) + + assert ( + service.project_name_exists(db_session, user_id=10001, project_name="已存在") + is True + ) + + +def test_name_exists_scoped_per_user(db_session): + service.create_project(db_session, **_fields(user_id=10001, project_name="共享名")) + + assert ( + service.project_name_exists(db_session, user_id=20002, project_name="共享名") + is False + ) + + +# -- get --------------------------------------------------------------------- + + +def test_get_returns_none_when_not_found(db_session): + assert service.get_project(db_session, 99999) is None + + +def test_get_returns_project_when_found(db_session): + created = service.create_project(db_session, **_fields(project_name="查询")) + + assert service.get_project(db_session, created.id).project_name == "查询" + + +# -- list -------------------------------------------------------------------- + + +def test_list_empty(db_session): + items, total = service.list_projects(db_session, page=1, page_size=20) + + assert items == [] + assert total == 0 + + +def test_list_paginates_and_orders_by_id_desc(db_session): + for i in range(5): + service.create_project(db_session, **_fields(project_name=f"p{i}")) + + items, total = service.list_projects(db_session, page=1, page_size=2) + + assert total == 5 + assert [p.project_name for p in items] == ["p4", "p3"] + + +def test_list_second_page(db_session): + for i in range(5): + service.create_project(db_session, **_fields(project_name=f"p{i}")) + + items, total = service.list_projects(db_session, page=2, page_size=2) + + assert total == 5 + assert [p.project_name for p in items] == ["p2", "p1"] + + +def test_list_filters_by_user(db_session): + service.create_project(db_session, **_fields(user_id=10001, project_name="a")) + service.create_project(db_session, **_fields(user_id=20002, project_name="b")) + + items, total = service.list_projects( + db_session, page=1, page_size=20, user_id=20002 + ) + + assert total == 1 + assert [p.project_name for p in items] == ["b"] + + +# -- delete ------------------------------------------------------------------ + + +def test_delete_returns_false_when_not_found(db_session): + assert service.delete_project(db_session, 99999) is False + + +def test_delete_removes_and_returns_true(db_session): + created = service.create_project(db_session, **_fields(project_name="删除")) + + assert service.delete_project(db_session, created.id) is True + assert service.get_project(db_session, created.id) is None + + +def test_delete_can_roll_back_the_whole_project_tree(db_session, monkeypatch): + project = service.create_project(db_session, **_fields(project_name="回滚")) + character = Character(project_id=project.id, character_data={"outfits": []}) + db_session.add(character) + db_session.flush() + inspection = PlaytestInspection( + character_id=character.id, + outfit_id="default", + action_id="idle", + status="passed", + ) + db_session.add(inspection) + db_session.commit() + project_id = project.id + character_id = character.id + inspection_id = inspection.id + + original_flush = db_session.flush + + def fail_flush(*_args, **_kwargs): + raise RuntimeError("forced transaction failure") + + monkeypatch.setattr(db_session, "flush", fail_flush) + with pytest.raises(RuntimeError, match="forced transaction failure"): + service.delete_project(db_session, project_id) + db_session.rollback() + monkeypatch.setattr(db_session, "flush", original_flush) + + assert db_session.get(Project, project_id) is not None + assert db_session.get(Character, character_id) is not None + assert db_session.get(PlaytestInspection, inspection_id) is not None diff --git a/backend/tests/test_redis_runtime.py b/backend/tests/test_redis_runtime.py new file mode 100644 index 00000000..fe98b884 --- /dev/null +++ b/backend/tests/test_redis_runtime.py @@ -0,0 +1,38 @@ +"""Redis 本地开发配置与启动入口。""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_redis_settings_default_and_environment_override(monkeypatch): + from windup_framework.config.redis import RedisSettings + + monkeypatch.delenv("REDIS_ENABLED", raising=False) + monkeypatch.delenv("REDIS_URL", raising=False) + assert RedisSettings().enabled is False + assert RedisSettings().url == "redis://127.0.0.1:6379/0" + + monkeypatch.setenv("REDIS_ENABLED", "true") + monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6380/2") + overridden = RedisSettings() + assert overridden.enabled is True + assert overridden.url == "redis://127.0.0.1:6380/2" + + +def test_disabled_redis_does_not_create_a_client(): + from windup_framework.config.redis import RedisSettings + from windup_framework.db.redis import create_redis_client + + assert create_redis_client(RedisSettings(enabled=False)) is None + + +def test_installer_sets_library_path_before_reusing_existing_runtime(): + installer = ( + REPO_ROOT / "backend" / "scripts" / "install-redis-wsl.sh" + ).read_text(encoding="utf-8") + + assert installer.index("export LD_LIBRARY_PATH") < installer.index( + 'if [[ -x "$redis_server"' + ) diff --git a/backend/uv.lock b/backend/uv.lock index cf241f39..4cc9e3b3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -20,138 +20,176 @@ dev = [ [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "anyio" version = "4.14.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "av" +version = "18.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" }, + { url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" }, + { url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" }, ] [[package]] name = "certifi" version = "2026.7.22" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39" }, - { url = "https://mirrors.aliyun.com/pypi/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] name = "click" version = "8.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "fastapi" version = "0.139.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -159,261 +197,362 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] name = "greenlet" version = "3.5.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, ] [[package]] name = "grimp" version = "3.15" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256" }, - { url = "https://mirrors.aliyun.com/pypi/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/4f/311bd40c02d61eee0182cb8c9b6ded37d42bed9a334e5ba4dacbe1c4c997/grimp-3.15-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6db0b30683612be4571b6b6208e1b39b77faa54566c5ca4f086d64cc783e4864" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/ce/86e941cc26bd3419b5e2abb8b48fa35b850e5508f14e033f3ce28bfce608/grimp-3.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e731a1f8a192a802e04d4018d6cce9f4a12ce48b6b73f43014bd4e0a5d04a8e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/09/bcb4e380596e533ef9ebb3f164ad3cc113fde62318ca5af71a27886b1612/grimp-3.15-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb607ee3e16440fc59ec2b49eef1b6dbbe701af9e99990b6491e6f120bd60b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/84/361cebbd7b14ce15d93bfb65e2b0ff30287252ffa6ab0b7fe08e029eae6c/grimp-3.15-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0492b9f1120176146d81e51aa0691aa6c004cdf5192ab309a221f9b223dedfc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/d6/c4dd785e149c3c66494822c8b46f0a36cbdf5a5400eeb2172e0a8b029d0f/grimp-3.15-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bb3dbb0471179e732400fdf31c8c8d0299c88d13dd3a3bbe77ce54fb3f1b545" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/4b/470922cb9d0a4b0436bb298801f770c98c6953374ff84e545dd7a196aa66/grimp-3.15-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b578512dbaee7eba2900d8078eaf1f7f78aa7616c609b0849b8403012ffddda" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/e1/061dd80f5f0abb3ac53b29ade25ffac3e464e6853e8ce31dc6c6a33a06a9/grimp-3.15-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a550a14cd2f8123fb06814b705f3af093fa1728b244f21ebcccc8d0da0f851" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/a3/32281e7b5fcd5622f4666b36003b9931ca0e112f2955f09458f198a30f65/grimp-3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db5ebb94ec356eaa5242145c993bc84c4b52d0d2c6980dfcd12b22d51c5b2c46" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/4b/b4f6ae15484541decbe4f12cf39a4a769352cb06332e428cc8e64aed4a32/grimp-3.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e03331418d7fee746e2447ecf5296986e6f094ef15fd25125efad2328844edf7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/a2/7bdc628435a1e908aa53b351ce031a6134efd18245c4a5a4c28e1e6d19aa/grimp-3.15-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4cc91cb69e73e7899feb6ce73747f4dc092c2bfe2653f6cba69a398cd1dfc6ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/18/77853f5693d607d5f49c7e3cd58e482ef8362ea9cf86d0fcb108e4168195/grimp-3.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a848d5b4b656c1e3a28cce0d399f2790ecbeb2eeb78bb49896018614c87219f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/bd/a094d7dd7115e8764e8069d5d3a44045c333b41d98a5746e99ec712b4b18/grimp-3.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:208ef56397cabfab52864b3d8a461fb5015a056fede1be4587e4453b13aa8c2f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/85/68/013c640df50968b5d25802a5f01b157514d4e0ccd48c37c63947610a0e05/grimp-3.15-cp314-cp314-win32.whl", hash = "sha256:74d32fae3d222888f6b61579998043579dd810ed948785896e552906cbfdfcb7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/0b/648a1d77dfc5c2fd24fbdca0a45ee1c24669d981d12652424f5c34e3352c/grimp-3.15-cp314-cp314-win_amd64.whl", hash = "sha256:6b36e179d485c797e7fa234803620af752039fa27a8c7e3182333d7c96041f70" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/c6/fd88ea799d181c22ab47c6cb328e7be3e196c8395444af89fd8da401cf6b/grimp-3.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9107d1037ee6e250ab7a15e1b758e0af76c841c19838b3cc688885a0b3817b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/d9/25377ba259772edfa97e35e265d89eb89b966a82652967c8479902741067/grimp-3.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b88975b2882edc55e8946640f7b36dfb83cce1303cff5f8528c3f4819d7fbb07" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/64/cae8ca43e05aa5217ca2e17ffbda77e86cb215c1a9a03592c110c0162f27/grimp-3.15-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fe01a5393e415e3d99b22c7dc6c6a9cc1b633714707d1c6b56ecbaccc2132f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/32/feea8fec71e62394013865314854ccb3d7bb54f226564fd267e6662f509a/grimp-3.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd4d70a0de010a9b452b59326a00574f7488dd1333d003df624114e5e00e877e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/ab/46ccdeb698398264d774273a9b8d9b013c8d23127d05371489b22dcf3ea5/grimp-3.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:204beed673ff43ab4ebe52ff4efd29b80025ec777136a62109afb69792d7fae0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/ae/f98c2c964a850ab80341d02576ef8681e75178f893ba2c73dc03e6563925/grimp-3.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602be339f8a4d368d71758814e5505200cf665818eb0f9a2cc74d71ecc8cf1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/38/9355cdb28fab458fb8defca6406de303d78af617aa0d8c9ae5253b4e5a8b/grimp-3.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:077c27a1e6ff3baf23a8caaa1d74cbbac27024b069956dd6ca5fc3acd43ddb4b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/06/edfa7f8064c1a3502fe4aba56c07bd122e94e329188939d52c02bd7e85b6/grimp-3.15-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5516fc38899f4396eff68e6b1997310b4de2d0155e3fad9f545e666c993985b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/c2/153b5cc3106814504b4195c7d4c178d85732d35b8895185a02112b8f9a03/grimp-3.15-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbe8e14cc61af4eea8e7ed6f2108828101f57fe86273d5b61cff044b69e6c6b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/3f/ae4ba51cf484030e3d15b3304eb7ab9039fe91fb35ff5e90d60fde135bff/grimp-3.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1763b7b48ed6c9e6de838d0d64f19510a392235266cf2240c9be9cc25ca7c54b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/5f/0fb2d2bc9fe6bce899947802c94531c24e581c715db19216b941c1b2422f/grimp-3.15-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:116c3a6dba61bd302919b82cb5d603f5977e321d80d7de3c7a1265357ab9dd66" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/ac/05d859a62ed9282f43a0f0962da230c46a2eb3d3ecb5b39117b3b4888405/grimp-3.15-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc2e5065d35047729e41a55c9c3eb99810cf322fe3b3e89fa126f306ce6b3b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/dd/f726316f54e29da4f24d545d6299e1ea0accfbbf52729077fd4a619de055/grimp-3.15-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b38327673df49203cff0ebcbbf078999a80f0b11bb654760059f6f00f56f64d6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/56/523a8f969f0a0b0a8ce43fbe8bc7a04e90f52724efc85f3305f1e07ae626/grimp-3.15-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965b37dad2f73164a103381c9fd1255bb3b2f6021ca2f38ffe70dd00fdc0fc55" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928", size = 2143914, upload-time = "2026-07-03T12:08:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db", size = 2089296, upload-time = "2026-07-03T12:08:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840", size = 2254829, upload-time = "2026-07-03T12:07:34.365Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256", size = 2193553, upload-time = "2026-07-03T12:07:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d", size = 2345941, upload-time = "2026-07-03T12:08:18.082Z" }, + { url = "https://files.pythonhosted.org/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886", size = 2604369, upload-time = "2026-07-03T12:07:55.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82", size = 2326619, upload-time = "2026-07-03T12:08:05.931Z" }, + { url = "https://files.pythonhosted.org/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d", size = 2272955, upload-time = "2026-07-03T12:08:30.488Z" }, + { url = "https://files.pythonhosted.org/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0", size = 2431271, upload-time = "2026-07-03T12:08:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73", size = 2466937, upload-time = "2026-07-03T12:09:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f", size = 2504734, upload-time = "2026-07-03T12:09:17.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6", size = 2514448, upload-time = "2026-07-03T12:09:28.401Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e", size = 1855806, upload-time = "2026-07-03T12:09:48.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9", size = 1985556, upload-time = "2026-07-03T12:09:40.786Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197, upload-time = "2026-07-03T12:08:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342, upload-time = "2026-07-03T12:08:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509, upload-time = "2026-07-03T12:07:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831, upload-time = "2026-07-03T12:07:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289, upload-time = "2026-07-03T12:08:19.458Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407, upload-time = "2026-07-03T12:07:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769, upload-time = "2026-07-03T12:08:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498, upload-time = "2026-07-03T12:08:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075, upload-time = "2026-07-03T12:08:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361, upload-time = "2026-07-03T12:09:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500, upload-time = "2026-07-03T12:09:19.585Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834, upload-time = "2026-07-03T12:09:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743, upload-time = "2026-07-03T12:09:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546, upload-time = "2026-07-03T12:09:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4f/311bd40c02d61eee0182cb8c9b6ded37d42bed9a334e5ba4dacbe1c4c997/grimp-3.15-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6db0b30683612be4571b6b6208e1b39b77faa54566c5ca4f086d64cc783e4864", size = 2144799, upload-time = "2026-07-03T12:08:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/86e941cc26bd3419b5e2abb8b48fa35b850e5508f14e033f3ce28bfce608/grimp-3.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e731a1f8a192a802e04d4018d6cce9f4a12ce48b6b73f43014bd4e0a5d04a8e9", size = 2090802, upload-time = "2026-07-03T12:08:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/6c/09/bcb4e380596e533ef9ebb3f164ad3cc113fde62318ca5af71a27886b1612/grimp-3.15-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb607ee3e16440fc59ec2b49eef1b6dbbe701af9e99990b6491e6f120bd60b5", size = 2255870, upload-time = "2026-07-03T12:07:37.207Z" }, + { url = "https://files.pythonhosted.org/packages/c3/84/361cebbd7b14ce15d93bfb65e2b0ff30287252ffa6ab0b7fe08e029eae6c/grimp-3.15-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0492b9f1120176146d81e51aa0691aa6c004cdf5192ab309a221f9b223dedfc", size = 2193359, upload-time = "2026-07-03T12:07:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d6/c4dd785e149c3c66494822c8b46f0a36cbdf5a5400eeb2172e0a8b029d0f/grimp-3.15-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bb3dbb0471179e732400fdf31c8c8d0299c88d13dd3a3bbe77ce54fb3f1b545", size = 2346350, upload-time = "2026-07-03T12:08:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/4b/470922cb9d0a4b0436bb298801f770c98c6953374ff84e545dd7a196aa66/grimp-3.15-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b578512dbaee7eba2900d8078eaf1f7f78aa7616c609b0849b8403012ffddda", size = 2604959, upload-time = "2026-07-03T12:07:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/061dd80f5f0abb3ac53b29ade25ffac3e464e6853e8ce31dc6c6a33a06a9/grimp-3.15-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a550a14cd2f8123fb06814b705f3af093fa1728b244f21ebcccc8d0da0f851", size = 2327185, upload-time = "2026-07-03T12:08:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/32281e7b5fcd5622f4666b36003b9931ca0e112f2955f09458f198a30f65/grimp-3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db5ebb94ec356eaa5242145c993bc84c4b52d0d2c6980dfcd12b22d51c5b2c46", size = 2273241, upload-time = "2026-07-03T12:08:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/b4f6ae15484541decbe4f12cf39a4a769352cb06332e428cc8e64aed4a32/grimp-3.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e03331418d7fee746e2447ecf5296986e6f094ef15fd25125efad2328844edf7", size = 2433114, upload-time = "2026-07-03T12:09:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/a2/7bdc628435a1e908aa53b351ce031a6134efd18245c4a5a4c28e1e6d19aa/grimp-3.15-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4cc91cb69e73e7899feb6ce73747f4dc092c2bfe2653f6cba69a398cd1dfc6ea", size = 2467235, upload-time = "2026-07-03T12:09:10.677Z" }, + { url = "https://files.pythonhosted.org/packages/5d/18/77853f5693d607d5f49c7e3cd58e482ef8362ea9cf86d0fcb108e4168195/grimp-3.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a848d5b4b656c1e3a28cce0d399f2790ecbeb2eeb78bb49896018614c87219f3", size = 2506552, upload-time = "2026-07-03T12:09:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/a094d7dd7115e8764e8069d5d3a44045c333b41d98a5746e99ec712b4b18/grimp-3.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:208ef56397cabfab52864b3d8a461fb5015a056fede1be4587e4453b13aa8c2f", size = 2514255, upload-time = "2026-07-03T12:09:31.383Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/013c640df50968b5d25802a5f01b157514d4e0ccd48c37c63947610a0e05/grimp-3.15-cp314-cp314-win32.whl", hash = "sha256:74d32fae3d222888f6b61579998043579dd810ed948785896e552906cbfdfcb7", size = 1856182, upload-time = "2026-07-03T12:09:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/648a1d77dfc5c2fd24fbdca0a45ee1c24669d981d12652424f5c34e3352c/grimp-3.15-cp314-cp314-win_amd64.whl", hash = "sha256:6b36e179d485c797e7fa234803620af752039fa27a8c7e3182333d7c96041f70", size = 1985451, upload-time = "2026-07-03T12:09:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/fd88ea799d181c22ab47c6cb328e7be3e196c8395444af89fd8da401cf6b/grimp-3.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9107d1037ee6e250ab7a15e1b758e0af76c841c19838b3cc688885a0b3817b5", size = 2253182, upload-time = "2026-07-03T12:07:39.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/d9/25377ba259772edfa97e35e265d89eb89b966a82652967c8479902741067/grimp-3.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b88975b2882edc55e8946640f7b36dfb83cce1303cff5f8528c3f4819d7fbb07", size = 2191822, upload-time = "2026-07-03T12:07:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/cae8ca43e05aa5217ca2e17ffbda77e86cb215c1a9a03592c110c0162f27/grimp-3.15-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fe01a5393e415e3d99b22c7dc6c6a9cc1b633714707d1c6b56ecbaccc2132f5", size = 2344413, upload-time = "2026-07-03T12:08:21.976Z" }, + { url = "https://files.pythonhosted.org/packages/39/32/feea8fec71e62394013865314854ccb3d7bb54f226564fd267e6662f509a/grimp-3.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd4d70a0de010a9b452b59326a00574f7488dd1333d003df624114e5e00e877e", size = 2602856, upload-time = "2026-07-03T12:08:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/46ccdeb698398264d774273a9b8d9b013c8d23127d05371489b22dcf3ea5/grimp-3.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:204beed673ff43ab4ebe52ff4efd29b80025ec777136a62109afb69792d7fae0", size = 2326095, upload-time = "2026-07-03T12:08:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/69/ae/f98c2c964a850ab80341d02576ef8681e75178f893ba2c73dc03e6563925/grimp-3.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602be339f8a4d368d71758814e5505200cf665818eb0f9a2cc74d71ecc8cf1c", size = 2274375, upload-time = "2026-07-03T12:08:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/9355cdb28fab458fb8defca6406de303d78af617aa0d8c9ae5253b4e5a8b/grimp-3.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:077c27a1e6ff3baf23a8caaa1d74cbbac27024b069956dd6ca5fc3acd43ddb4b", size = 2430307, upload-time = "2026-07-03T12:09:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/edfa7f8064c1a3502fe4aba56c07bd122e94e329188939d52c02bd7e85b6/grimp-3.15-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5516fc38899f4396eff68e6b1997310b4de2d0155e3fad9f545e666c993985b3", size = 2464014, upload-time = "2026-07-03T12:09:12.185Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c2/153b5cc3106814504b4195c7d4c178d85732d35b8895185a02112b8f9a03/grimp-3.15-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbe8e14cc61af4eea8e7ed6f2108828101f57fe86273d5b61cff044b69e6c6b5", size = 2502010, upload-time = "2026-07-03T12:09:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3f/ae4ba51cf484030e3d15b3304eb7ab9039fe91fb35ff5e90d60fde135bff/grimp-3.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1763b7b48ed6c9e6de838d0d64f19510a392235266cf2240c9be9cc25ca7c54b", size = 2514787, upload-time = "2026-07-03T12:09:33Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/0fb2d2bc9fe6bce899947802c94531c24e581c715db19216b941c1b2422f/grimp-3.15-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:116c3a6dba61bd302919b82cb5d603f5977e321d80d7de3c7a1265357ab9dd66", size = 2345635, upload-time = "2026-07-03T12:08:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/00/ac/05d859a62ed9282f43a0f0962da230c46a2eb3d3ecb5b39117b3b4888405/grimp-3.15-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc2e5065d35047729e41a55c9c3eb99810cf322fe3b3e89fa126f306ce6b3b5", size = 2273647, upload-time = "2026-07-03T12:08:36.139Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dd/f726316f54e29da4f24d545d6299e1ea0accfbbf52729077fd4a619de055/grimp-3.15-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b38327673df49203cff0ebcbbf078999a80f0b11bb654760059f6f00f56f64d6", size = 2344080, upload-time = "2026-07-03T12:08:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/523a8f969f0a0b0a8ce43fbe8bc7a04e90f52724efc85f3305f1e07ae626/grimp-3.15-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965b37dad2f73164a103381c9fd1255bb3b2f6021ca2f38ffe70dd00fdc0fc55", size = 2275041, upload-time = "2026-07-03T12:08:37.582Z" }, ] [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" version = "0.8.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] [[package]] name = "idna" version = "3.18" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3", size = 390173, upload-time = "2026-07-20T05:26:11.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6", size = 318000, upload-time = "2026-07-20T05:26:09.874Z" }, ] [[package]] name = "import-linter" version = "2.13" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "grimp" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27" } +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5" }, + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, ] [[package]] name = "jsonpatch" version = "1.33" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpointer" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c" } +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade" }, + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, ] [[package]] name = "jsonpointer" version = "3.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] [[package]] name = "langchain-core" version = "1.5.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, { name = "langchain-protocol" }, @@ -425,27 +564,41 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/05/986c4bb148285791eb59994e0b28947bed96cac7f24467079e4274952a37/langchain_core-1.5.0.tar.gz", hash = "sha256:e1fa09d55b354192c8f60dade06a55bd6add2318c822a684555b8d4a30a16143" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/05/986c4bb148285791eb59994e0b28947bed96cac7f24467079e4274952a37/langchain_core-1.5.0.tar.gz", hash = "sha256:e1fa09d55b354192c8f60dade06a55bd6add2318c822a684555b8d4a30a16143", size = 967401, upload-time = "2026-07-21T03:37:26.48Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65" }, + { url = "https://files.pythonhosted.org/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65", size = 558510, upload-time = "2026-07-21T03:37:24.423Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/fc/d146705e0cf6cf8865d4e873e0551452f94d6520f43fe703594ccdf95763/langchain_openai-1.4.0.tar.gz", hash = "sha256:a3acf6be0937f3970fc9e7f0aae22929c6f117e49128bd62f4d45a64b2587d8b", size = 3261776, upload-time = "2026-07-21T16:09:22.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/6c/f786dfcb6711cb06449041e72b67f7e28fc77a53e2aa16866c4f0002625a/langchain_openai-1.4.0-py3-none-any.whl", hash = "sha256:7a777731fe32a913085ec85bacd5650c3f8422048b65346b63a13b36b1b4a12f", size = 121901, upload-time = "2026-07-21T16:09:21.61Z" }, ] [[package]] name = "langchain-protocol" version = "0.0.18" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a" }, + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, ] [[package]] name = "langgraph" version = "1.2.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, @@ -454,41 +607,41 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4" } +sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76" }, + { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, ] [[package]] name = "langgraph-checkpoint" version = "4.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25" } +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e" }, + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, ] [[package]] name = "langgraph-prebuilt" version = "1.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528" } +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9" }, + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, ] [[package]] name = "langgraph-sdk" version = "0.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "langchain-core" }, @@ -496,15 +649,15 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] name = "langsmith" version = "0.10.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -521,275 +674,345 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ed/6d/9ad4427662ef131878f0f928d5a9e9913e0dda4b6511bb03e8722f1dee8a/langsmith-0.10.9.tar.gz", hash = "sha256:195bc67c964a6370cb91742ce9fa07ce69bfae47977f0fb3f41d125b3435d03a" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/6d/9ad4427662ef131878f0f928d5a9e9913e0dda4b6511bb03e8722f1dee8a/langsmith-0.10.9.tar.gz", hash = "sha256:195bc67c964a6370cb91742ce9fa07ce69bfae47977f0fb3f41d125b3435d03a", size = 4736750, upload-time = "2026-07-20T21:27:13.738Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ed/54/509a1eb6b9e5572a4f3f4b087779d240c6b69f3d5247ffa21314f66155d9/langsmith-0.10.9-py3-none-any.whl", hash = "sha256:5e0e8ab0f8df05710809919184495e33c2a7c9a9a5e8861d63dd12c1226d9c79" }, + { url = "https://files.pythonhosted.org/packages/ed/54/509a1eb6b9e5572a4f3f4b087779d240c6b69f3d5247ffa21314f66155d9/langsmith-0.10.9-py3-none-any.whl", hash = "sha256:5e0e8ab0f8df05710809919184495e33c2a7c9a9a5e8861d63dd12c1226d9c79", size = 673326, upload-time = "2026-07-20T21:27:11.889Z" }, ] [[package]] name = "markdown-it-py" version = "4.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] name = "numpy" version = "2.5.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, + { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, + { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, +] + +[[package]] +name = "openai" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8", size = 1081965, upload-time = "2026-07-28T22:16:31.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa", size = 1650721, upload-time = "2026-07-28T22:16:29.48Z" }, ] [[package]] name = "orjson" version = "3.11.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624" }, - { url = "https://mirrors.aliyun.com/pypi/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] name = "ormsgpack" version = "1.12.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, ] [[package]] name = "packaging" version = "26.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pillow" version = "12.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930" }, - { url = "https://mirrors.aliyun.com/pypi/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] name = "psycopg" version = "3.3.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc" } +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, ] [package.optional-dependencies] @@ -800,169 +1023,178 @@ binary = [ [[package]] name = "psycopg-binary" version = "3.3.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829" }, - { url = "https://mirrors.aliyun.com/pypi/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8" }, +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] [[package]] name = "pydantic" version = "2.13.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" version = "2.46.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] name = "pydantic-settings" version = "2.14.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] name = "pygments" version = "2.20.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" version = "2.13.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, ] [[package]] name = "pytest" version = "9.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -970,328 +1202,508 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "python-dotenv" version = "1.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" version = "0.0.32" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qiniu" +version = "7.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/e5/82e5078de1204b641d6b24fdd15b3c5a87a7dd71f514e4a3cfb845a2d988/qiniu-7.18.0.tar.gz", hash = "sha256:d9edca3a1c5217c13638a08d9095cd1661f5ba6cf92ea3827949ff3d332ea4fa", size = 85627, upload-time = "2026-06-30T07:25:02.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df", size = 109452, upload-time = "2026-06-30T07:24:58.291Z" }, +] + +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, ] [[package]] name = "requests" version = "2.34.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "requests-toolbelt" version = "1.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] [[package]] name = "rich" version = "15.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" version = "0.15.22" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "sqlalchemy" version = "2.0.51" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] name = "starlette" version = "1.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] [[package]] name = "tenacity" version = "9.1.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2026.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] name = "urllib3" version = "2.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uuid-utils" version = "0.17.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/20/80/a7e685968e3cec99d6fe2fb25d0f5726310e1bba356da68c13dfd8b7d140/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/47/3102d93bcb7b0bfe6bede63ff8f221a7f91348e10a37f682773be27c56d9/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/fb/d59695f0f8db065b93c63316eaafa05a22d75a0486978a33736c52c646d5/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/03/62fabcd1e990e07a0e220e8d552af45bc16f107fa8e55c2014a706bb1a1e/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/37/a5081391338b459e2f8d8b12581f00f8caa6317fab510e0e85c18c59e938/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/30/91795bd01e17a13661280d4899fbf38fb05e3f38e873f9aaec106ec30aa0/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/11/09102b78303e4eb62069d6d88ef9fd661dc523e8f429e1fd67eaa78a6f44/uuid_utils-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/f9/be95bad6954b60328878c3800258f01a6accd24fd75112d13f023462d53f/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/02/8a19a34e0530d987488a068a71576a236f5c8c746630b870b57f71eb24ef/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/a8/b1abab36ff73b0248d82179816467f6d39a2e80fd64329a895ca94f3508e/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/91/70e7b528b351cc03a9ca43e6116371cdde31bb12bcead7ca2ca1367366cc/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/f6/9167e90cf9937d6558f92d022ff3024a69d938a514d9c8faa4080f73b001/uuid_utils-0.17.0-cp312-cp312-win32.whl", hash = "sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/7d/0b889654d9ee3413f810cf4685e241285f650d98a4103ac9f3c6bcc95f29/uuid_utils-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/35/8c6e1bf65e4d400352885dadc656ad6d0af96e89231e3f04686bc2197128/uuid_utils-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/44/e2fd3fdf356e1b55d2acf1b956b4f3f29ffb215a99c387eba04b1c5fba66/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:673d89cc434cc9b97a0b4cf61272f6fca70a81f64eb0afbface2a0d9f77f06cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/28/65e0980d668a6d44e699f59d1acf43d6b5d4893592c115ce7c680bb4dfa1/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:387cf7437c94ddec08651a0f1081381299c7075bc48a6251d8922bf39973378a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/8d/5e97bcebc90fb6a10f98af3dc1ba552e04183aba59e2edc0b9cf486dd998/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:220b52746d99e11964badac3c0869016e0c24bafb70a7dd5c2c072a6be3da9cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/d7/88b2a2370cc3d455ba0515fb6f5c8f7ac0c0f55a86801b6e56a432f22c17/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ab4a66e7a035ad6625cfc1fbdb34f5c2d25a80ae1ef4bfee458ea2036333c6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/0f/181c5da673953dfc0958cb4fb3a4984a9098673ddb05cac68e994bc8511b/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5641071337eb11d61a001ea08793bf72216f3241f0a433ed2764804b2a3e3cc7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/38/5c5e665af542884a8fd3c61725c38453239e13940326b5b70f3ef8881a97/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9082e709014946b1f6e96ae6ecd93652efca2d2a6a3ab67dbe151c8b4bf193a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/35/7de97de18cbf226c2a4f2104ad15e56ca4491717c81c0b71795c0c585b4e/uuid_utils-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1fd6f0e8a162dc0e9255b6aebe3cd175e76c33202f1bf39da9e6294b93db0099" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/a1/9915d5dd59fdd1957ded5d188c0ea0b9db5a1d84d42c8d8828a7b83b366e/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d63010803d7c368963bbe6f7ec379593e76dd581d7db0f29118d88713c9e0354" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/05/88108405262ec850cea0f95733445d6873e5772af3292baabd9ef8457740/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a46bedc273b6f58f11dee816ff74999625ef8d007890f411b7a4975bf1c89330" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/d5/6dbcd300de47cc443cff2656cd5327a385751213dcb2101cfee7388170b2/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:405233a5f625b3d995648f4647fa6befa4567cf3f74e1f6b9837e16f7310f0e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/94/e8057f2288a415fba8a978bca4b589f5cb6b91a028a5dc07a1775938b33f/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6c5d2d71e1f17329150ad9427d27f4a3f29a01792e7ecdc64a98ac5368fc4d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/6b/31713148c77e48e62f51aa042a98a54a8be0396912ea5130f83f52ae722d/uuid_utils-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7e9b8728ba07a3cb2f29d5aa1a266c2664eb8ef0fd43afa34627c92f7fac8f0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/f3/ca6f6ac5428312df8ed632f6dd9f9e6aba23090471fcdeae53eab027e8b3/uuid_utils-0.17.0-cp314-cp314-win32.whl", hash = "sha256:58838921e377791ef22c64cc92141bfae030f43651ff9272f0f28a208a9e6a5a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/cd/7ede0db66411fa09817d79b680f7454ea9bee2d374e1922e4efd065760a3/uuid_utils-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:42275ebd0e8e74e32cdbfb8bd88fc99576567d51d54a508020611fd8f4f463a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/81/533b5f80cd4918c0693f4e1b7b90ceb1caa45f4266ae8b528135d7ecca5d/uuid_utils-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:b5d11cccba076a32321ef1380dea956821f0b51794ef59df64e58fb1cd543aae" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/13/f400ac39d06fd8be5b099c09e41bb975205926722a3e8d53348817cb7ff9/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fae8b282f0cb22a5de222999f7723f4e5ec04f6fcdf4aaef879b5b36625ae2b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/8c/c71c8312304c56f6d0bcba87cd402fa79bec35d18ffc8c41954196ca68e5/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:967955620df45e6cffe2e9950cb9903cb455649396f896b26b04363a91a5054b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/cd/522117e2e5184ca1d4f0f85ee833e9e21bd8c6b99eff8a4d1a8e5a194e33/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375cde148430d60a4a07c03abaa0774c4fddfdd90de99b4ba02f24088bc9d750" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/f4/0d81f9bd346fc717bc561c08fa6457e0328966eb76e536b938fe77d56459/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:975c17da26c5b9d46c336b03c52a057ac28378d6f9d98b58d32a038589bb3912" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/41/26e1363f36a94c9e8ec2dd21d5f63088d3e7c723adbb12dcc8fdc77be417/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3150d836290c88f1d26eb59c4db280d87417dd3bfaadd2889c77416c8f0ff6fa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/a7/2c1ed1b34d7df7fdcc11c28fd26d94d44843b37d9af2435ff9fd8abdbc08/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9472a8de37faf8bd216c628e0e68c8f6bef730d3ba0a5060f3b0fa460c992ac2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/bf/328d3c6bb22c496944a1b3b732207d71aa6964eb604e5e3b9dcb91ed0a00/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d27c531edb8d1f38ca2eddaa1fa24913a460aeb721f2efd4ef42a124ce94e354" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/76/a07de5cb7b90582fdbbc830fd19be129cbbb9897cfe239fef469d7bd2d09/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5670c52a438e21483ce715776144914a4e2a2a5c62d9dee15f8a3e90cf128ae6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/62/9966e46ae34fcec6b06119631fb3c09705ea78835035ce3a82d3348eb61a/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6f29689a76fe7a49cbd629a794d0ec1eab48814e323a00a146a741b0195bde68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/4e/bb962ba0fe31e903b199f22cf4c1a6cba35a8987aef526d287277ab8ca8b/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4441600447d340ae103a353f01dbcd22ff680e5ee1a22988efe8d7b791d8fdb3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/9e/122adfeeeae8a84ccfd43bce627b104d12a2180a93bffd2c0e1b54dad7a6/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b04935a79c03c41ad08d0a5f390aac968bfb561f1268897bc5b0f077971efd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/4f/257304dded339dc35fc9bf35722ac68fd4fdb930f255b8f7bccdf74ebba9/uuid_utils-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:239d8a281fe10bae33205b5d43185834d556b18434e0a113b5dc1dfb2fd97e91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/35/c8/e78c06db7e9ce317ce7b8759ff2058333eac75caa8c22b75f0059589c9be/uuid_utils-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e288a06cbbbcd01b44386e767985c9e21d2ad9bf59829aa7058d9a2a494804ab" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/11/bd1c70e1ad3301163cebe66c8d26de26e6814d52f642a849448bd2833626/uuid_utils-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/80/a7e685968e3cec99d6fe2fb25d0f5726310e1bba356da68c13dfd8b7d140/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287", size = 556403, upload-time = "2026-07-09T13:48:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/3102d93bcb7b0bfe6bede63ff8f221a7f91348e10a37f682773be27c56d9/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968", size = 285608, upload-time = "2026-07-09T13:48:28.769Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/d59695f0f8db065b93c63316eaafa05a22d75a0486978a33736c52c646d5/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6", size = 319926, upload-time = "2026-07-09T13:48:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/03/62fabcd1e990e07a0e220e8d552af45bc16f107fa8e55c2014a706bb1a1e/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988", size = 327172, upload-time = "2026-07-09T13:48:31.187Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/a5081391338b459e2f8d8b12581f00f8caa6317fab510e0e85c18c59e938/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94", size = 439075, upload-time = "2026-07-09T13:48:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/59/30/91795bd01e17a13661280d4899fbf38fb05e3f38e873f9aaec106ec30aa0/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc", size = 320247, upload-time = "2026-07-09T13:48:33.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/09102b78303e4eb62069d6d88ef9fd661dc523e8f429e1fd67eaa78a6f44/uuid_utils-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a", size = 344738, upload-time = "2026-07-09T13:48:34.786Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/be95bad6954b60328878c3800258f01a6accd24fd75112d13f023462d53f/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c", size = 496845, upload-time = "2026-07-09T13:48:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/02/8a19a34e0530d987488a068a71576a236f5c8c746630b870b57f71eb24ef/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a", size = 603233, upload-time = "2026-07-09T13:48:37.512Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a8/b1abab36ff73b0248d82179816467f6d39a2e80fd64329a895ca94f3508e/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206", size = 561401, upload-time = "2026-07-09T13:48:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/70e7b528b351cc03a9ca43e6116371cdde31bb12bcead7ca2ca1367366cc/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc", size = 525314, upload-time = "2026-07-09T13:48:40.599Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/9167e90cf9937d6558f92d022ff3024a69d938a514d9c8faa4080f73b001/uuid_utils-0.17.0-cp312-cp312-win32.whl", hash = "sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d", size = 166831, upload-time = "2026-07-09T13:48:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/0b889654d9ee3413f810cf4685e241285f650d98a4103ac9f3c6bcc95f29/uuid_utils-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637", size = 172944, upload-time = "2026-07-09T13:48:42.992Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/8c6e1bf65e4d400352885dadc656ad6d0af96e89231e3f04686bc2197128/uuid_utils-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652", size = 172459, upload-time = "2026-07-09T13:48:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63", size = 557259, upload-time = "2026-07-09T13:48:45.664Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73", size = 286271, upload-time = "2026-07-09T13:48:47.018Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9", size = 320025, upload-time = "2026-07-09T13:48:48.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1", size = 327931, upload-time = "2026-07-09T13:48:49.673Z" }, + { url = "https://files.pythonhosted.org/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098", size = 438537, upload-time = "2026-07-09T13:48:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869", size = 320656, upload-time = "2026-07-09T13:48:52.164Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131", size = 345310, upload-time = "2026-07-09T13:48:54.076Z" }, + { url = "https://files.pythonhosted.org/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb", size = 496771, upload-time = "2026-07-09T13:48:55.365Z" }, + { url = "https://files.pythonhosted.org/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3", size = 603631, upload-time = "2026-07-09T13:48:56.746Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64", size = 562008, upload-time = "2026-07-09T13:48:58.241Z" }, + { url = "https://files.pythonhosted.org/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89", size = 525527, upload-time = "2026-07-09T13:48:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e", size = 97965, upload-time = "2026-07-09T13:49:01.217Z" }, + { url = "https://files.pythonhosted.org/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c", size = 167316, upload-time = "2026-07-09T13:49:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff", size = 173630, upload-time = "2026-07-09T13:49:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f", size = 173214, upload-time = "2026-07-09T13:49:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/56/44/e2fd3fdf356e1b55d2acf1b956b4f3f29ffb215a99c387eba04b1c5fba66/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:673d89cc434cc9b97a0b4cf61272f6fca70a81f64eb0afbface2a0d9f77f06cd", size = 562232, upload-time = "2026-07-09T13:49:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/19/28/65e0980d668a6d44e699f59d1acf43d6b5d4893592c115ce7c680bb4dfa1/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:387cf7437c94ddec08651a0f1081381299c7075bc48a6251d8922bf39973378a", size = 287858, upload-time = "2026-07-09T13:49:07.45Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/5e97bcebc90fb6a10f98af3dc1ba552e04183aba59e2edc0b9cf486dd998/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:220b52746d99e11964badac3c0869016e0c24bafb70a7dd5c2c072a6be3da9cc", size = 321587, upload-time = "2026-07-09T13:49:09.489Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/88b2a2370cc3d455ba0515fb6f5c8f7ac0c0f55a86801b6e56a432f22c17/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ab4a66e7a035ad6625cfc1fbdb34f5c2d25a80ae1ef4bfee458ea2036333c6d", size = 328964, upload-time = "2026-07-09T13:49:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/181c5da673953dfc0958cb4fb3a4984a9098673ddb05cac68e994bc8511b/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5641071337eb11d61a001ea08793bf72216f3241f0a433ed2764804b2a3e3cc7", size = 442909, upload-time = "2026-07-09T13:49:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/ec/38/5c5e665af542884a8fd3c61725c38453239e13940326b5b70f3ef8881a97/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9082e709014946b1f6e96ae6ecd93652efca2d2a6a3ab67dbe151c8b4bf193a4", size = 323076, upload-time = "2026-07-09T13:49:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/f5/35/7de97de18cbf226c2a4f2104ad15e56ca4491717c81c0b71795c0c585b4e/uuid_utils-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1fd6f0e8a162dc0e9255b6aebe3cd175e76c33202f1bf39da9e6294b93db0099", size = 347360, upload-time = "2026-07-09T13:49:15.237Z" }, + { url = "https://files.pythonhosted.org/packages/26/a1/9915d5dd59fdd1957ded5d188c0ea0b9db5a1d84d42c8d8828a7b83b366e/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d63010803d7c368963bbe6f7ec379593e76dd581d7db0f29118d88713c9e0354", size = 499267, upload-time = "2026-07-09T13:49:16.774Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/88108405262ec850cea0f95733445d6873e5772af3292baabd9ef8457740/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a46bedc273b6f58f11dee816ff74999625ef8d007890f411b7a4975bf1c89330", size = 604940, upload-time = "2026-07-09T13:49:18.147Z" }, + { url = "https://files.pythonhosted.org/packages/89/d5/6dbcd300de47cc443cff2656cd5327a385751213dcb2101cfee7388170b2/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:405233a5f625b3d995648f4647fa6befa4567cf3f74e1f6b9837e16f7310f0e0", size = 564172, upload-time = "2026-07-09T13:49:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ab/94/e8057f2288a415fba8a978bca4b589f5cb6b91a028a5dc07a1775938b33f/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6c5d2d71e1f17329150ad9427d27f4a3f29a01792e7ecdc64a98ac5368fc4d5", size = 528533, upload-time = "2026-07-09T13:49:21.075Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6b/31713148c77e48e62f51aa042a98a54a8be0396912ea5130f83f52ae722d/uuid_utils-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7e9b8728ba07a3cb2f29d5aa1a266c2664eb8ef0fd43afa34627c92f7fac8f0", size = 99197, upload-time = "2026-07-09T13:49:22.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/ca6f6ac5428312df8ed632f6dd9f9e6aba23090471fcdeae53eab027e8b3/uuid_utils-0.17.0-cp314-cp314-win32.whl", hash = "sha256:58838921e377791ef22c64cc92141bfae030f43651ff9272f0f28a208a9e6a5a", size = 169540, upload-time = "2026-07-09T13:49:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cd/7ede0db66411fa09817d79b680f7454ea9bee2d374e1922e4efd065760a3/uuid_utils-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:42275ebd0e8e74e32cdbfb8bd88fc99576567d51d54a508020611fd8f4f463a0", size = 175984, upload-time = "2026-07-09T13:49:24.703Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/533b5f80cd4918c0693f4e1b7b90ceb1caa45f4266ae8b528135d7ecca5d/uuid_utils-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:b5d11cccba076a32321ef1380dea956821f0b51794ef59df64e58fb1cd543aae", size = 174749, upload-time = "2026-07-09T13:49:25.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/f400ac39d06fd8be5b099c09e41bb975205926722a3e8d53348817cb7ff9/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fae8b282f0cb22a5de222999f7723f4e5ec04f6fcdf4aaef879b5b36625ae2b0", size = 562610, upload-time = "2026-07-09T13:49:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/c71c8312304c56f6d0bcba87cd402fa79bec35d18ffc8c41954196ca68e5/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:967955620df45e6cffe2e9950cb9903cb455649396f896b26b04363a91a5054b", size = 289473, upload-time = "2026-07-09T13:49:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/522117e2e5184ca1d4f0f85ee833e9e21bd8c6b99eff8a4d1a8e5a194e33/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375cde148430d60a4a07c03abaa0774c4fddfdd90de99b4ba02f24088bc9d750", size = 321600, upload-time = "2026-07-09T13:49:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f4/0d81f9bd346fc717bc561c08fa6457e0328966eb76e536b938fe77d56459/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:975c17da26c5b9d46c336b03c52a057ac28378d6f9d98b58d32a038589bb3912", size = 329569, upload-time = "2026-07-09T13:49:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/26e1363f36a94c9e8ec2dd21d5f63088d3e7c723adbb12dcc8fdc77be417/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3150d836290c88f1d26eb59c4db280d87417dd3bfaadd2889c77416c8f0ff6fa", size = 442051, upload-time = "2026-07-09T13:49:33.024Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/2c1ed1b34d7df7fdcc11c28fd26d94d44843b37d9af2435ff9fd8abdbc08/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9472a8de37faf8bd216c628e0e68c8f6bef730d3ba0a5060f3b0fa460c992ac2", size = 324372, upload-time = "2026-07-09T13:49:34.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/bf/328d3c6bb22c496944a1b3b732207d71aa6964eb604e5e3b9dcb91ed0a00/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d27c531edb8d1f38ca2eddaa1fa24913a460aeb721f2efd4ef42a124ce94e354", size = 348548, upload-time = "2026-07-09T13:49:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/76/a07de5cb7b90582fdbbc830fd19be129cbbb9897cfe239fef469d7bd2d09/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5670c52a438e21483ce715776144914a4e2a2a5c62d9dee15f8a3e90cf128ae6", size = 498985, upload-time = "2026-07-09T13:49:37.142Z" }, + { url = "https://files.pythonhosted.org/packages/f4/62/9966e46ae34fcec6b06119631fb3c09705ea78835035ce3a82d3348eb61a/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6f29689a76fe7a49cbd629a794d0ec1eab48814e323a00a146a741b0195bde68", size = 605183, upload-time = "2026-07-09T13:49:38.648Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4e/bb962ba0fe31e903b199f22cf4c1a6cba35a8987aef526d287277ab8ca8b/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4441600447d340ae103a353f01dbcd22ff680e5ee1a22988efe8d7b791d8fdb3", size = 565412, upload-time = "2026-07-09T13:49:40.115Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/122adfeeeae8a84ccfd43bce627b104d12a2180a93bffd2c0e1b54dad7a6/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b04935a79c03c41ad08d0a5f390aac968bfb561f1268897bc5b0f077971efd", size = 529885, upload-time = "2026-07-09T13:49:41.513Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/257304dded339dc35fc9bf35722ac68fd4fdb930f255b8f7bccdf74ebba9/uuid_utils-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:239d8a281fe10bae33205b5d43185834d556b18434e0a113b5dc1dfb2fd97e91", size = 169472, upload-time = "2026-07-09T13:49:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/35/c8/e78c06db7e9ce317ce7b8759ff2058333eac75caa8c22b75f0059589c9be/uuid_utils-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e288a06cbbbcd01b44386e767985c9e21d2ad9bf59829aa7058d9a2a494804ab", size = 176271, upload-time = "2026-07-09T13:49:44.105Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/bd1c70e1ad3301163cebe66c8d26de26e6814d52f642a849448bd2833626/uuid_utils-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9", size = 175004, upload-time = "2026-07-09T13:49:45.591Z" }, ] [[package]] name = "uvicorn" version = "0.51.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [package.optional-dependencies] @@ -1307,150 +1719,150 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "watchfiles" version = "1.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]] name = "websockets" version = "15.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] @@ -1458,6 +1870,8 @@ name = "windup-ai-engine" version = "0.1.0" source = { editable = "packages/ai_engine" } dependencies = [ + { name = "av" }, + { name = "imageio" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "numpy" }, @@ -1468,6 +1882,8 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "av", specifier = ">=14.0" }, + { name = "imageio", specifier = ">=2.36" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.2" }, { name = "numpy", specifier = ">=1.26" }, @@ -1520,10 +1936,17 @@ version = "0.1.0" source = { editable = "packages/framework" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-openai" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "qiniu" }, + { name = "redis" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -1531,10 +1954,17 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, + { name = "langchain-core", specifier = ">=0.3" }, + { name = "langchain-openai", specifier = ">=0.3" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "onnxruntime", specifier = ">=1.17,<1.24" }, + { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, + { name = "qiniu", specifier = ">=7.14" }, + { name = "redis", specifier = ">=5.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] @@ -1542,169 +1972,169 @@ requires-dist = [ [[package]] name = "xxhash" version = "3.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92" }, - { url = "https://mirrors.aliyun.com/pypi/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, + { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, + { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, + { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, + { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, ] [[package]] name = "zstandard" version = "0.25.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902" }, - { url = "https://mirrors.aliyun.com/pypi/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, ] diff --git a/docs/module-split-plan.md b/docs/module-split-plan.md new file mode 100644 index 00000000..921704a1 --- /dev/null +++ b/docs/module-split-plan.md @@ -0,0 +1,110 @@ +# 前端模块拆分规划(chaifen 系列) + +> 决策日期:2026-08-04 +> 核心源目录:`.pr70-playtest-worktree`(分支 `feat/playtest-on-module-skeleton`) +> 拆分目标目录:`chaifen/` +> 范围决策:**只拆前端**;后端不拆分(见「后端(决策:不拆分)」)。 + +## 1. 拆分模式(01-04 已验证) + +1. 每个模块 = `chaifen/0X-` worktree + 独立分支 + 独立 PR(推送到 `1024XEngineer/Windup`)。 +2. 每个 PR **只含模块自身文件**,通过「允许提交路径」白名单控制范围;配套源码在本地保留、不提交。 +3. 新模块基于上一个已验证提交创建,形成顺序依赖链。 +4. 每模块一份 `_PR说明.md` 管理文档(本地 exclude,不提交),记录范围、验证、改动记录。 + +## 2. 模块总表 + +### 已完成(01-06) + +| # | 模块 | 分支 | 内容 | 状态 | +|---|---|---|---|---| +| 01 | workflow-run-core | `feat/workflow-run-core-clean` | `entities/workflow-run`(model/store/service) | PR #86 open | +| 02 | workflow-controller | `feat/workflow-controller-coordinator` | `features/workflow-controller`(controller.ts + tests) | 已推送 `5ec5ba3`,未开 PR | +| 03 | playtest | `split/playtest` | 数据适配器(`shared/api`、`entities/character\|project\|playtest-inspection`)+ 预览/质量内核 + 页面与路由(`pages/playtest`,含 `app.tsx`/`layout` 集成) | 三个提交齐备(`8f9abeb`),未推送 | +| 04 | quick-start | `split/quick-start` | `pages/quick-start`(index/service + tests) | PR #95 draft | +| 05 | export-package | `feat/export-package` | `features/export-package` 全量 + 契约(`contract.ts`、`cocos-target.ts`、schema、gifenc.d.ts)+ package.json/lock | PR #97 draft,已推送 `3cbfbc9` | +| 06 | history | `feat/history-page` | `pages/history`(index + tests) | PR #105 draft,已推送 | + +### 进行中(07-09) + +| # | 模块 | 分支 | 内容 | 状态 | +|---|---|---|---|---| +| 07 | media-upload | `feat/media-upload` | `entities/media` 适配器 + `shared/api/upload.ts` | Refs #109;文件就绪、未提交 | +| 08 | generation-sse-adapter | `feat/generation-sse-adapter` | `entities/generation` 适配器 + `shared/api/stream.ts` | Refs #78;验证通过(lint/typecheck/test/build)、未提交 | +| 09 | asset-library | — | `pages/asset-library/index.tsx` | 目录已建,worktree 待初始化 | + +### 待拆(剩余) + +| # | 模块 | 包含文件(白名单) | 说明 | +|---|---|---|---| +| — | publish-review | `features/publish/index.ts`、`workflow-to-character.ts`、`features/review/index.ts` | 原计划 06,顺延 | +| — | workflow-editor | `pages/workflow-editor/index.tsx`、`node-canvas.ts`、`service.ts`、`workflow-canvas.tsx`、`workflow-editor.css` | 原计划 07 | +| — | projects | `pages/projects/index.tsx`、`create-page.tsx`、`detail-page.tsx` | 原计划 10 | +| — | app-shell | `app/` 剩余(layout/`__fixtures__`/api-contract.test)、`pages/not-found/index.tsx`、`main.tsx`、`index.css` | 最后收口 | +| — | 规划外残留 | `pages/home/*`、`features/character-setup/*`、`features/export/index.ts`、`features/generation/index.ts`、`entities/project/index.ts`、playtest 剩余 4 文件、`shared/pagination` | 见备注 | + +### 后端(决策:不拆分) + +2026-08-04 决策:**不拆分后端**。`docs/module-split.md` 仅作为后端模块架构说明 +(接口 / 模型 / 实现的组织方式),不作为拆分执行计划。后端改动直接在主分支常规流程推进。 + +## 3. 执行顺序与依赖 + +``` +01 → 02 → 03 → 04 → 05 → 06(history) → 07(media) → 08(generation) → 09(asset-library) +→ publish-review → workflow-editor → projects → app-shell(收口) +``` + +- 01-06 已完成;07/08 文件就绪、待提交;09 待初始化。 +- 07/08 基于 `main`(7ee5a98)自包含,不依赖功能分支。 +- publish-review、workflow-editor 依赖 02-controller 与 01 entities,需在依赖合入后重放。 +- app-shell 最后拆,负责把全部模块收口进 `app/` 路由。 + +> **编号说明:** 实际序列与原规划不同——06 拆的是 history(原 08),07/08 为新增的 +> media-upload 与 generation-sse-adapter,原 06=publish-review、07=workflow-editor +> 顺延待拆。目录编号以 chaifen/ 实际为准,不再回填。 + +## 4. 每模块执行步骤 + +1. 在核心工作树确认模块文件完整(从核心源 `git add` 精确收集)。 +2. `git worktree add chaifen/0X- -b split/`,基于上一个已验证提交(或 01 的 `feat/workflow-run-core-clean`)。 +3. 新 worktree 基于基础提交展开(该提交已含模块骨架版本),从核心源逐文件复制模块的真实实现覆盖到对应路径(含 index.ts 入口与测试)。 +4. 验证:格式(oxlint/prettier)、TypeScript、单元测试、生产构建通过。 +5. 提交(conventional commits),更新 `_PR说明.md`(范围、验证结果、改动记录)。 +6. 需要时推送到贡献者分支开 PR(是否推送由用户确认)。 + +## 5. 验证标准(每模块) + +- [ ] 格式检查通过(允许路径文件) +- [ ] Lint 通过 +- [ ] TypeScript 通过 +- [ ] 单元测试通过 +- [ ] 生产构建通过 +- [ ] 范围检查:`upstream/main...HEAD` 差异只含白名单文件 + +## 6. 改动记录 + +### 2026-08-04 规划落盘 + +- 首次编写本规划:前端 05-11 拆分范围、后端 B 系列留待以后、执行顺序与验证标准。 +- 决策:只拆前端、逐模块推进、规划写入 docs/。 + +### 2026-08-04 决策:后端不拆分 + +- 明确**不拆分后端**:`docs/module-split.md` 仅作架构说明,后端改动走常规流程。 +- 更新范围决策与模块总表说明。 + +### 2026-08-04 状态同步(第二次检索) + +- 05/06 已完成:05 增补 `3cbfbc9` 契约提交并推送;06 推送 `d2105bc`(PR #105)。 +- 03 补齐第三个提交 `8f9abeb`(页面与路由集成,含 `app.tsx`/`layout` 改动),未推送。 + - ⚠️ 该提交越过了 playtest 边界进入 app-shell 文件;11 拆分时以主工作树完整版 + `app.tsx` 为准。 +- 02 已推送 `5ec5ba3`(rebase 后哈希变化),未开 PR。 +- 新增 07 media-upload(Refs #109)、08 generation-sse-adapter(Refs #78): + 文件就绪、验证通过、未提交;09 asset-library 仅建目录,worktree 待初始化。 +- 原规划编号作废:06=publish-review、07=workflow-editor、08=history;publish-review + 与 workflow-editor 顺延待拆(见「待拆(剩余)」)。 +- 清理中间产物:主工作树根目录 5 张截图、06 本地预览文件 + (`history-preview.html`/`preview.tsx`)、02/06 的 node_modules 与 dist。 +- 模块总表重写为实际状态;后续模块完成或推进时同步更新本表。 diff --git a/docs/module-split.md b/docs/module-split.md index 3dd8d743..0b35a306 100644 --- a/docs/module-split.md +++ b/docs/module-split.md @@ -127,10 +127,10 @@ CharacterData ## 4. generation — AI 生成任务 -**接口:** `GenerationService` **传输:** SSE 推送任务状态 +**接口:** `GenerationService` **目标传输:** SSE 推送任务状态(当前实现仍为轮询) -职责:管理生成任务生命周期,按任务类型区分入参和出参。前端通过 SSE 订阅任务 -状态变更,无需轮询。 +职责:管理生成任务生命周期,按任务类型区分入参和出参。当前前端每 2 秒查询任务 +快照;目标是通过 SSE 订阅任务状态变化,接口见 `sse-generation-flow.md`。 **任务类型与出参对应关系:** @@ -158,7 +158,7 @@ CharacterData | `generate_character_action(input)` | 提交角色动作生成任务 | | `get_task(project_id, task_id)` | 查询任务状态与结果 | -**SSE 调用流程:** +**目标 SSE 调用流程:** 1. 前端 POST 提交任务,拿到 `task_id`。 2. 前端连接 `GET /generation/tasks/{task_id}/stream`,服务端在任务状态变化时 @@ -169,7 +169,8 @@ CharacterData > **与旧设计的差异:** 不再使用策略模式(`GenerationStrategy` / `register_strategy` / > `submit(payload)`),改为按任务类型拆分明确的接口方法。不再使用泛化出参 > `GenerationResult(urls, metadata)`,改为按任务类型细化出参 -> `CharacterImageOutput` / `CharacterActionOutput`。不再使用前端轮询,改为 SSE 推送。 +> `CharacterImageOutput` / `CharacterActionOutput`。SSE 尚未落地,当前前端轮询将在 +> stream 接口完成后替换。 --- diff --git a/docs/sse-generation-flow.md b/docs/sse-generation-flow.md new file mode 100644 index 00000000..969e9c71 --- /dev/null +++ b/docs/sse-generation-flow.md @@ -0,0 +1,242 @@ +# SSE 生成全流程与接口 + +## 1. 当前实现状态 + +当前生成任务已经具备异步任务模型,但**尚未实现 SSE 接口**: + +- 后端支持提交角色图、提交动作和查询任务快照。 +- 后台线程执行 AI 生成、上传图片并更新任务状态。 +- 前端 `GenerationApis.subscribe()` 每 2 秒调用一次查询接口,属于轮询。 +- `GET /generation/tasks/{task_id}/stream` 目前不存在。 + +因此,下文将“当前已经可用的接口”和“需要实现的 SSE 接口”分开描述。 + +## 2. 生成制作全流程 +-+-++++++++++++++++++++++++ +```mermaid +sequenceDiagram + participant UI as Quick Start / Workflow Editor + participant API as Generation API + participant DB as GenerationTask + participant Worker as Generation Executor + participant AI as AI Engine + participant Storage as Object Storage + participant SSE as SSE Stream + participant Character as Character API + participant Playtest as Playtest + + UI->>API: POST /generation/image 或 /generation/action + API->>DB: 创建 PENDING 任务 + API-->>UI: 返回 task_id 和任务快照 + API->>Worker: 启动后台生成 + UI->>SSE: 连接任务 stream + SSE-->>UI: 推送 PENDING 当前快照 + Worker->>DB: 状态改为 RUNNING + SSE-->>UI: 推送 RUNNING + Worker->>AI: 按项目视角、尺寸和画风生成 + AI-->>Worker: 返回角色图或动作帧 + Worker->>Storage: 上传生成图片 + Storage-->>Worker: 返回图片 URL + Worker->>DB: 写入结果并标记 COMPLETED + SSE-->>UI: 推送 COMPLETED 和 result + UI->>UI: 回填当前制作步骤并等待用户确认 + UI->>Character: 用户确认后写入角色、造型和动作 + Playtest->>Character: 读取已确认 Character +``` + +失败路径:Worker 捕获异常,将任务改为 `FAILED` 并写入 `error_message`;SSE 推送失败事件后关闭连接。断开 SSE 只停止接收消息,不取消后台任务。 + +## 3. 当前已经实现的接口 + +Base URL:`http://127.0.0.1:8000` + +### 3.1 提交角色图生成 + +`POST /generation/image` + +```json +{ + "user_id": 1, + "project_id": 37, + "reference_image_url": null, + "prompt": "侧视像素风守夜人", + "negative_prompt": "", + "width": 256, + "height": 256, + "num_images": 4 +} +``` + +后端创建 `character_image` 任务。输入宽高必须与项目精灵尺寸一致。 + +### 3.2 提交动作生成 + +`POST /generation/action` + +```json +{ + "user_id": 1, + "project_id": 37, + "character_id": 25, + "action_type": "custom", + "custom_prompt": "举起并挥动灯笼", + "reference_video_url": null, + "reference_image_urls": ["https://example.com/master.png"], + "num_frames": 32 +} +``` + +后端创建 `character_action` 任务。生成器读取项目视角、画风和精灵尺寸,逐帧上传后返回完整帧列表。 + +### 3.3 查询任务快照 + +`GET /generation/tasks/{task_id}?project_id={project_id}` + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 71, + "user_id": 1, + "project_id": 37, + "task_type": "character_action", + "status": "completed", + "input_payload": {}, + "result": { + "type": "character_action", + "action_type": "custom", + "frames": [ + { + "index": 0, + "image_url": "https://example.com/frame-0.png", + "duration_ms": 125 + } + ] + }, + "error_message": null + } +} +``` + +任务状态只有:`pending`、`running`、`completed`、`failed`。当前模型没有百分比 `progress` 字段。 + +## 4. 需要新增的 SSE 接口 + +### 4.1 订阅任务状态 + +`GET /generation/tasks/{task_id}/stream?project_id={project_id}` + +响应头: + +```http +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +X-Accel-Buffering: no +``` + +连接建立后,服务端必须立即推送任务当前快照,不能等待下一次状态变化。任务进入终态后推送最后一条消息并关闭连接。 + +### 4.2 任务事件 + +```text +event: task_update +id: 71:2 +retry: 2000 +data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"running","result":null,"error_message":null} + +``` + +完成事件: + +```text +event: task_update +id: 71:3 +data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"completed","result":{"type":"character_action","action_type":"custom","frames":[]},"error_message":null} + +``` + +失败事件: + +```text +event: task_update +id: 71:3 +data: {"task_id":71,"project_id":37,"task_type":"character_action","status":"failed","result":null,"error_message":"母版下载失败"} + +``` + +事件字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `task_id` | int | 生成任务 ID | +| `project_id` | int | 所属项目 ID | +| `task_type` | string | `character_image` 或 `character_action` | +| `status` | string | 四种任务状态之一 | +| `result` | object/null | 仅完成时存在 | +| `error_message` | string/null | 仅失败时存在 | + +### 4.3 心跳事件 + +服务端每 15 秒发送一次心跳,避免代理关闭空闲连接: + +```text +event: ping +data: {} + +``` + +心跳不进入业务状态机,前端可以忽略。 + +## 5. 后端处理规则 + +1. 建立流之前校验 `project_id + task_id`,不存在返回业务码 404。 +2. 建立连接后立即查询数据库并推送当前快照。 +3. 只在状态或结果变化时推送 `task_update`,不重复发送同一快照。 +4. `completed` 或 `failed` 推送后关闭连接。 +5. 客户端断开时释放监听资源,但不停止 GenerationTask。 +6. 客户端重连时不依赖内存中的旧连接,重新读取数据库最新状态。 +7. MVP 不添加虚假的百分比进度;若以后需要逐帧进度,应先扩展任务模型和事件表。 + +当前后台任务使用线程执行。单进程 MVP 可以使用任务通知队列唤醒 SSE;多进程部署时需要 Redis Pub/Sub、数据库通知或持久事件表,不能只依赖进程内 `asyncio.Queue`。 + +## 6. 前端接入规则 + +`GenerationApis` 的业务接口保持不变: + +```ts +interface GenerationApis { + create(input: GenerationInput): Promise + get(projectId: string, taskId: string): Promise + subscribe( + projectId: string, + taskId: string, + onEvent: (event: GenerationEvent) => void, + ): () => void +} +``` + +只替换 `subscribe()` 的传输实现: + +```ts +const source = new EventSource( + `/generation/tasks/${taskId}/stream?project_id=${encodeURIComponent(projectId)}`, +) + +source.addEventListener('task_update', (message) => { + onEvent(JSON.parse(message.data)) +}) + +return () => source.close() +``` + +Workflow Controller 继续消费统一的 `GenerationEvent`,不应知道底层使用 SSE 还是轮询。页面刷新后先调用 `get()` 读取任务最新快照;若仍是 `pending/running`,再恢复 SSE 订阅。 + +## 7. 模块责任边界 + +- `generation`:创建任务、执行生成、保存任务结果、推送 SSE。 +- `workflow-controller`:把任务结果写入当前前端制作步骤。 +- `character`:只在用户确认后保存最终角色和动作。 +- `playtest`:只读取已确认 Character 进行预览和核验,不订阅生成任务。 +- 当前不接历史记录,也不建设资产库流程。 diff --git a/docs/superpowers/plans/2026-08-05-home-auth-account.md b/docs/superpowers/plans/2026-08-05-home-auth-account.md new file mode 100644 index 00000000..7b7cab72 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-home-auth-account.md @@ -0,0 +1,206 @@ +# Home Authentication and Account Settings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add real email authentication, persistent sessions, protected product routes, and backend-supported account settings to the existing Windup homepage. + +**Architecture:** `entities/user` owns the exact backend contract; `features/auth-session` owns session state and token lifecycle; `pages/home` owns account UI; `app` performs composition and route protection. Refresh Token persists in localStorage while Access Token remains in memory and is exposed through the existing shared API token-provider boundary. + +**Tech Stack:** React 19, React Router 8, TypeScript 6, Vite 8, Tailwind CSS 4, Vitest, Testing Library. + +## Global Constraints + +- Implement only endpoints and fields present in `feat/user-module`. +- Do not add OAuth, avatar, nickname editing, email editing, account deletion, `application`, or `capabilities`. +- Keep the homepage public and protect all production product routes. +- Persist only Refresh Token under `windup.auth.refresh-token`; keep Access Token in memory. +- Preserve the existing homepage editorial grey-green visual language. + +--- + +### Task 1: User entity and real authentication adapter + +**Files:** +- Create: `frontend/src/entities/user/index.ts` +- Create: `frontend/src/entities/user/api.ts` +- Create: `frontend/src/entities/user/api.test.ts` +- Modify: `frontend/src/entities/index.ts` + +**Interfaces:** +- Consumes: `ApiClient` and `createApiClient` from `@/shared/api`. +- Produces: `User`, `AuthTokens`, `UserApis`, and `createUserApis(options?)`. + +- [ ] **Step 1: Write failing adapter tests** + +Cover literal request paths and bodies for `sendCode`, `register`, `login`, `loginByCode`, `refresh`, `logout`, `me`, and `changePassword`. Assert snake_case responses become camelCase: + +```ts +expect(await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })).toEqual({ + accessToken: 'access', + refreshToken: 'refresh', + user: { id: 7, email: 'a@b.com', nickname: null, emailVerifiedAt: null, status: 'normal' }, +}) +``` + +- [ ] **Step 2: Run the entity test and verify RED** + +Run: `npm test -- --run src/entities/user/api.test.ts` + +Expected: FAIL because `createUserApis` does not exist. + +- [ ] **Step 3: Implement the exact backend contract** + +Use these public signatures: + +```ts +interface UserApis { + sendCode(input: { email: string; purpose: 'login' | 'register' | 'reset_password' }): Promise + register(input: { email: string; password: string; code: string; nickname?: string }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} +``` + +- [ ] **Step 4: Run the entity tests and verify GREEN** + +Run: `npm test -- --run src/entities/user/api.test.ts` + +Expected: PASS. + +### Task 2: Persistent authentication session + +**Files:** +- Create: `frontend/src/features/auth-session/session-storage.ts` +- Create: `frontend/src/features/auth-session/session-storage.test.ts` +- Create: `frontend/src/features/auth-session/index.tsx` +- Create: `frontend/src/features/auth-session/index.test.tsx` + +**Interfaces:** +- Consumes: `UserApis`, `AuthTokens`, `registerApiAccessTokenProvider`. +- Produces: `AuthSessionProvider`, `useAuthSession`, `ProtectedRoute`. + +- [ ] **Step 1: Write failing storage and provider tests** + +Test that only Refresh Token enters localStorage, bootstrap refreshes and fetches `/auth/me`, login stores the rotated Refresh Token, logout clears local state even when the backend rejects, and guests are redirected with a safe `returnTo`. + +- [ ] **Step 2: Run the session tests and verify RED** + +Run: `npm test -- --run src/features/auth-session/session-storage.test.ts src/features/auth-session/index.test.tsx` + +Expected: FAIL because the session module does not exist. + +- [ ] **Step 3: Implement session state and route protection** + +Use this state contract: + +```ts +type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null } + | { status: 'authenticated'; user: User } +``` + +Register a token getter during Provider lifetime. Decode only the JWT `exp` payload to schedule refresh 60 seconds early; token signature validation remains a backend responsibility. Reject `returnTo` values that do not start with `/` or start with `//`. + +- [ ] **Step 4: Run the session tests and verify GREEN** + +Run: `npm test -- --run src/features/auth-session/session-storage.test.ts src/features/auth-session/index.test.tsx` + +Expected: PASS. + +### Task 3: Homepage account interface + +**Files:** +- Create: `frontend/src/pages/home/account-panel.tsx` +- Create: `frontend/src/pages/home/account-panel.test.tsx` +- Modify: `frontend/src/pages/home/index.tsx` +- Modify: `frontend/src/pages/home/index.test.tsx` + +**Interfaces:** +- Consumes: `useAuthSession` methods and URL query parameters. +- Produces: login, register, read-only profile, password change, and logout UI. + +- [ ] **Step 1: Write failing interaction tests** + +Cover opening the account panel, sending a code with the correct purpose, code login, password login with code, registration, readonly profile fields, password change, logout, and the absence of nickname/email/avatar editing controls. + +- [ ] **Step 2: Run homepage tests and verify RED** + +Run: `npm test -- --run src/pages/home/account-panel.test.tsx src/pages/home/index.test.tsx` + +Expected: FAIL because the account panel is missing. + +- [ ] **Step 3: Implement the homepage UI** + +Use a fixed backdrop and responsive panel aligned to the existing grey-green palette. Keep labels explicit, use native form controls, expose request errors with `role="alert"`, and keep submit buttons disabled during requests. Implement a 60-second send-code countdown without adding a timer dependency. + +- [ ] **Step 4: Run homepage tests and verify GREEN** + +Run: `npm test -- --run src/pages/home/account-panel.test.tsx src/pages/home/index.test.tsx` + +Expected: PASS. + +### Task 4: App composition, account entry, and protected routes + +**Files:** +- Modify: `frontend/src/app/app.tsx` +- Modify: `frontend/src/app/app.test.tsx` +- Modify: `frontend/src/app/layout/app-header.tsx` +- Modify: `frontend/src/app/layout/app-header.test.tsx` + +**Interfaces:** +- Consumes: `createUserApis`, `AuthSessionProvider`, `ProtectedRoute`. +- Produces: one shared User API instance, public homepage, and protected product routes. + +- [ ] **Step 1: Write failing route and header tests** + +Assert that a guest can render `/`, a guest entering `/quick-start` reaches `/?account=login&returnTo=%2Fquick-start`, and the header account entry displays “登录 / 注册” or the authenticated nickname. + +- [ ] **Step 2: Run app tests and verify RED** + +Run: `npm test -- --run src/app/app.test.tsx src/app/layout/app-header.test.tsx` + +Expected: FAIL because authentication is not composed. + +- [ ] **Step 3: Compose auth once and protect routes** + +Create `userApis` in the App composition root. Wrap `AppShell` and `Routes` in `AuthSessionProvider`. Keep `/` public and wrap every production product route (including quick start, projects, characters, asset library, history, workflow editor, and formal Playtest) in `ProtectedRoute`; keep the development Demo route public. + +- [ ] **Step 4: Run app tests and verify GREEN** + +Run: `npm test -- --run src/app/app.test.tsx src/app/layout/app-header.test.tsx` + +Expected: PASS. + +### Task 5: Full verification + +**Files:** +- Modify only files required by failures in the authentication scope. + +**Interfaces:** +- Consumes: all tasks above. +- Produces: verified frontend authentication delivery. + +- [ ] **Step 1: Run full automated verification** + +Run: + +```powershell +npm test +npm run format:check +npm run lint +npm run typecheck +npm run build +``` + +Expected: every command exits 0. + +- [ ] **Step 2: Inspect the final diff** + +Run: `git diff --check -- frontend/src/entities/user frontend/src/features/auth-session frontend/src/pages/home frontend/src/app` + +Expected: no whitespace errors and no backend file changes. diff --git a/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md b/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md new file mode 100644 index 00000000..0740f9c3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-uploaded-template-shortcut.md @@ -0,0 +1,157 @@ +# Uploaded Character Template Shortcut Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Quick Start and Workflow Editor upload a character image and proceed directly to 32-frame action generation, while preserving the existing text-only character-generation path. + +**Architecture:** One shared `MediaApis` instance is composed in App. The workflow state machine gains an explicit uploaded-template transition; Quick Start owns the existing character creation/action orchestration, and Workflow Editor delegates to that same use case instead of duplicating it. + +**Tech Stack:** React 19, TypeScript 6, Vitest, existing Workflow Controller and MediaApis. + +## Global Constraints + +- Uploaded image plus non-empty text generates a `custom` action using that text as the action prompt. +- Uploaded image plus blank text generates the default `idle` action. +- No uploaded image preserves the existing character-description, generated-candidate and confirmation flow. +- Uploaded images must never be represented as a successful backend image Generation task. +- Complete animation generation remains 32 frames. +- Add no dependency and no new business module. +- Preserve all unrelated dirty-worktree changes. + +--- + +### Task 1: Shared uploaded-template workflow transition and App composition + +**Files:** +- Modify: `frontend/src/features/workflow-controller/workflow-state.test.ts` +- Modify: `frontend/src/features/workflow-controller/workflow-state.ts` +- Modify: `frontend/src/features/workflow-controller/controller.ts` +- Modify: `frontend/src/app/app.tsx` +- Test: `frontend/src/app/app-composition.test.tsx` + +**Interfaces:** +- Produces: `WorkflowController.acceptUploadedCharacterTemplate(runId, templateUrl): WorkflowRun`. +- Produces: one `createMediaApis()` instance passed into Quick Start composition. + +- [ ] **Step 1: Write the failing state test** + +Assert that accepting `media-upload-1` on an initial create-character run yields statuses `passed, passed, passed, active, locked`, stores the reference on character setup, exposes it as the template/candidate output, keeps `generationStatus` as `not_started`, and leaves exactly one active step. + +- [ ] **Step 2: Run the state test and verify RED** + +Run: `npm.cmd test -- src/features/workflow-controller/workflow-state.test.ts` + +Expected: FAIL because `acceptUploadedCharacterTemplateState` does not exist. + +- [ ] **Step 3: Implement the pure transition and controller method** + +Add the pure state function and save its result through the controller. Reject blank references, non-active runs, and any active step other than `character-setup`. Do not create a Generation input or task ID. + +- [ ] **Step 4: Add shared App composition** + +Create `const mediaApis = createMediaApis()` beside other entity adapters, pass it to Quick Start, and wire Workflow Editor's uploaded-template callback to the Quick Start service method defined in Task 2. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: `npm.cmd test -- src/features/workflow-controller/workflow-state.test.ts src/app/app-composition.test.tsx` + +Expected: PASS. + +### Task 2: Quick Start upload button and direct-action use case + +**Files:** +- Modify: `frontend/src/pages/quick-start/service.test.ts` +- Modify: `frontend/src/pages/quick-start/service.ts` +- Modify: `frontend/src/pages/quick-start/index.test.tsx` +- Modify: `frontend/src/pages/quick-start/index.tsx` + +**Interfaces:** +- Consumes: `WorkflowController.acceptUploadedCharacterTemplate(runId, templateUrl)`. +- Produces: `QuickStartService.startWithUploadedTemplate(file, actionDescription, signal?)`. +- Produces: `QuickStartService.continueWithUploadedTemplate(runId, file, actionDescription, signal?)`, reused by Workflow Editor. + +- [ ] **Step 1: Write failing service tests** + +Cover non-empty text → `custom`, blank text → `idle`, `MediaApis.upload(file, 'reference-image', signal)`, no character-template Generation call, and upload failure before WorkflowRun creation/advance. + +- [ ] **Step 2: Write failing page tests** + +Assert an image button exists in the lower-right input actions, selecting an image allows blank-text submit, selected filename/removal are available, and removing the image restores the text-required rule. + +- [ ] **Step 3: Run Quick Start tests and verify RED** + +Run: `npm.cmd test -- src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx` + +Expected: FAIL because the methods and upload UI do not exist. + +- [ ] **Step 4: Implement the minimal service behavior** + +Prepare a project using trimmed action text or the selected filename as the naming seed, upload through `MediaApis`, create the run only after upload succeeds, accept the uploaded template, then reuse the existing character creation/action-generation function. Existing `start(prompt)` stays unchanged. + +- [ ] **Step 5: Implement the minimal UI** + +Use one hidden `input[type=file][accept="image/*"]`, a visible lower-right button, filename/removal controls, conditional placeholder/help copy, submit guarding, and an AbortController for the in-flight upload. + +- [ ] **Step 6: Run Quick Start tests and verify GREEN** + +Run: `npm.cmd test -- src/pages/quick-start/service.test.ts src/pages/quick-start/index.test.tsx` + +Expected: PASS. + +### Task 3: Workflow Editor uploaded-template shortcut + +**Files:** +- Modify: `frontend/src/pages/workflow-editor/service.ts` +- Modify: `frontend/src/pages/workflow-editor/index.test.tsx` +- Modify: `frontend/src/pages/workflow-editor/index.tsx` +- Modify: `frontend/src/pages/workflow-editor/workflow-canvas.tsx` +- Modify: `frontend/src/pages/workflow-editor/workflow-editor.css` + +**Interfaces:** +- Consumes: injected `continueWithUploadedTemplate(runId, file, actionDescription, signal?)` callback. +- Produces: `WorkflowEditorService.continueWithUploadedTemplate(...)` for the page. + +- [ ] **Step 1: Write failing page/service tests** + +Assert the active character-setup node can return `{ description, file }`; with a file the page calls the uploaded-template service once and does not call `nextStep`, while without a file it keeps `updateCharacterSetup` plus `nextStep`. + +- [ ] **Step 2: Run Workflow Editor tests and verify RED** + +Run: `npm.cmd test -- src/pages/workflow-editor/index.test.tsx` + +Expected: FAIL because the file control and service method do not exist. + +- [ ] **Step 3: Implement event and service delegation** + +Render the image input in the existing character-setup node. Extend delegated submit handling to pass the selected `File`. Route file submissions to the injected uploaded-template use case and text-only submissions to the unchanged path. Abort the upload when the run view unmounts. + +- [ ] **Step 4: Run Workflow Editor tests and verify GREEN** + +Run: `npm.cmd test -- src/pages/workflow-editor/index.test.tsx` + +Expected: PASS. + +### Task 4: Integration, regression and review + +**Files:** +- Modify only files required by concrete failures from the commands below. + +- [ ] **Step 1: Run the complete frontend suite** + +Run: `npm.cmd test` + +Expected: all tests pass. + +- [ ] **Step 2: Run production checks** + +Run: `npm.cmd run build`, `npm.cmd run lint`, `npm.cmd run format:check`, and repository `git diff --check`. + +Expected: all commands pass. + +- [ ] **Step 3: Review cross-entry consistency** + +Verify both entries share the same `MediaApis`, controller transition and character/action orchestration; verify text-only flows and historical runs remain unchanged. + +- [ ] **Step 4: Request final code review** + +Review for workflow-state validity, duplicate submission, abort behavior, error recovery, and accidental fake Generation results. Resolve all Critical and Important findings before completion. diff --git a/docs/superpowers/specs/2026-08-05-home-auth-account-design.md b/docs/superpowers/specs/2026-08-05-home-auth-account-design.md new file mode 100644 index 00000000..8ee02371 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-home-auth-account-design.md @@ -0,0 +1,86 @@ +# 首页登录与个人设置设计 + +## 目标 + +在现有 Windup 首页内完成真实邮箱认证和个人设置入口,严格对接 `xiaocheny214/DireSoul` 的 `feat/user-module` 后端,不伪造昵称编辑、头像、邮箱修改等后端不存在的能力。 + +## 已确认的产品边界 + +- 首页允许游客浏览。 +- 快速开始、工作流、项目、历史和正式 Playtest 路由需要登录。 +- 游客进入受保护路由时回到首页并打开登录界面;登录成功后恢复原目标。 +- 登录状态在浏览器关闭后继续保留,最长使用后端 7 天 Refresh Token 生命周期。 +- 个人资料只读展示邮箱、昵称、邮箱验证状态和账户状态。 +- 个人设置只允许修改密码与退出登录。 +- 不实现 OAuth、头像、昵称修改、邮箱修改或账号删除。 + +## 后端契约 + +接口来源为 `feat/user-module`: + +| 方法 | 路径 | 请求 | +| --- | --- | --- | +| POST | `/auth/send-code` | `{ email, purpose: 'login' \| 'register' \| 'reset_password' }` | +| POST | `/auth/register` | `{ email, password, code, nickname? }` | +| POST | `/auth/login` | `{ email, password, code }` | +| POST | `/auth/login-by-code` | `{ email, code }` | +| POST | `/auth/refresh` | `{ refresh_token }` | +| POST | `/auth/logout` | `{ refresh_token }` | +| GET | `/auth/me` | Bearer Access Token | +| POST | `/auth/change-password` | `{ old_password, new_password }` | + +登录、注册和刷新返回 `{ access_token, refresh_token, user }`。Access Token 生命周期为 15 分钟,Refresh Token 生命周期为 7 天。 + +## 前端结构 + +保持现有 `app → pages → features → entities → shared`: + +- `entities/user`:用户类型、认证 DTO 转换和八个真实 HTTP 方法。 +- `features/auth-session`:唯一认证状态、Refresh Token 持久化、Access Token 内存保存、自动刷新和路由保护。 +- `pages/home/account-panel.tsx`:首页登录、注册和个人设置界面。 +- `app`:装配 User API、认证 Provider、受保护路由和顶栏账户入口。 + +不增加 `application` 或 `capabilities`。 + +## Token 生命周期 + +Refresh Token 保存到 `localStorage` 的 `windup.auth.refresh-token`。Access Token 只保存在 React 内存状态,通过现有 `registerApiAccessTokenProvider` 注入全部业务请求。 + +页面启动时: + +1. 没有 Refresh Token,进入游客状态。 +2. 有 Refresh Token,调用 `/auth/refresh` 轮换两个 Token。 +3. 使用新 Access Token 调用 `/auth/me`,取得完整用户资料。 +4. 任何刷新失败都清除本地 Refresh Token并进入游客状态。 + +登录成功后根据 JWT `exp` 在到期前 60 秒自动刷新。浏览器从后台恢复时重新检查有效期。退出登录先调用后端;即使网络失败也清除本地会话,避免界面继续显示已登录。 + +## 首页交互 + +首页维持现有灰绿、墨黑、纸张质感的编辑式视觉。右上角增加账户按钮:游客显示“登录 / 注册”,登录后显示昵称,昵称为空时显示邮箱前缀。 + +账户界面覆盖在首页之上: + +- 登录包含“验证码登录”和“密码登录”两个模式;后端要求密码登录也提交验证码。 +- 注册包含邮箱、验证码、密码和可选昵称。 +- 发码按钮带 60 秒倒计时,避免重复请求。 +- 个人设置显示账户资料、修改密码表单和退出登录按钮。 +- 所有请求错误使用后端统一错误消息,不把失败解释成成功。 + +## 路由保护 + +首页 `/` 和开发环境 `/playtest/demo` 保持公开。其余产品路由使用同一个 `ProtectedRoute`。游客访问时跳转到: + +`/?account=login&returnTo=<原 pathname + search>` + +登录成功只允许恢复以 `/` 开头的站内地址;非法或缺失目标回到首页。 + +## 验收 + +- User API 的八个方法严格匹配后端路径、字段和响应。 +- Refresh Token 可跨浏览器重启恢复会话,Access Token 不写入持久存储。 +- 注册、两种登录、刷新、资料读取、改密和退出都有错误状态。 +- 游客可以浏览首页,但不能进入受保护页面。 +- 登录成功恢复原目标。 +- 个人设置不存在后端未提供的编辑功能。 +- 全量测试、格式、Lint、类型检查和生产构建通过。 diff --git a/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md b/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md new file mode 100644 index 00000000..08f03e15 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-uploaded-template-shortcut-design.md @@ -0,0 +1,58 @@ +# 上传角色母版直达动作生成设计 + +## 目标 + +Quick Start 与 Workflow Editor 都允许用户在首次输入时选择一张角色图片。选择图片后,前端将其上传为角色母版,跳过角色图片生成和候选选择,直接创建角色并生成 32 帧动作资产。未选择图片时,原有角色图片生成流程保持不变。 + +## 用户交互 + +### Quick Start + +- 在输入区域右下角增加图片上传按钮,只接受 `image/*`。 +- 选择图片后显示文件名并允许替换或移除;文件在用户提交时上传。 +- 有图片时,输入框文字解释为动作描述:非空生成 `custom` 动作,空白生成默认 `idle` 动作。 +- 没有图片时,输入框仍是角色描述并继续现有候选图流程,因此必须填写文字。 + +### Workflow Editor + +- 在 `character-setup` 节点增加图片选择入口。 +- 有图片时,该节点中的文字解释为动作描述,并在一次提交后直接进入动作生成。 +- 没有图片时,文字仍是角色描述,继续角色图生成和候选确认。 + +## 架构与数据流 + +1. App 只创建一个现有 `MediaApis` 实例,并通过页面 Service 注入,页面不直接拼 HTTP 请求。 +2. 提交图片时调用现有 `MediaApis.upload(file, 'reference-image', signal)`,得到不透明的 `MediaReference`。 +3. Workflow Controller 增加明确的“采用已上传角色母版”状态转换: + - `character-setup` 标记为 `passed`,记录上传媒体引用; + - `character-template` 标记为 `passed`,输出该上传图片; + - `template-candidate` 标记为 `passed`,记录该图片已被选定; + - `action-generation` 标记为 `active`。 +4. 该转换不创建 Generation 图片任务,也不伪造后端生成成功。 +5. Quick Start 复用现有角色创建与动作生成编排。Workflow Editor 委托同一用例,不维护第二套角色或动作生成逻辑。 +6. 动作输入使用上传图片作为 `firstFrameUrl` 和参考媒体;文字非空时使用 `custom`,空白时使用 `idle`。 +7. 动作完成后继续复用现有审核、角色更新、Playtest 发布和历史恢复流程。 + +## 一致性与失败处理 + +- 图片类型由前端提前检查,后端仍做最终校验。 +- Quick Start 先创建项目,再上传图片;上传失败时不创建 WorkflowRun,也不开始生成。 +- Workflow Editor 上传失败时保持 `character-setup` 为 `active`,用户可以重试。 +- 重复点击提交时只允许一项在途上传;页面离开时中止仍在途请求。 +- 上传成功但动作任务提交失败时,WorkflowRun 按现有动作生成失败规则记录错误,不静默回退到角色图生成。 +- 历史 16 帧或无上传图片的 WorkflowRun 仍按原契约恢复。 + +## 测试范围 + +- Quick Start 页面:右下角上传按钮、选图后允许空文字提交、移除图片后恢复文字必填。 +- Quick Start Service:上传后不调用角色图片 Generation,前三步合法通过,动作生成接收上传引用和动作描述;空文字生成 `idle`。 +- Workflow Editor 页面:有图时一次提交直达上传用例,无图时仍调用原 `nextStep`。 +- Workflow 状态:上传快捷转换保持固定五节点顺序,恰好一个 active 步骤,并可被本地存储恢复。 +- 失败:上传失败不推进 WorkflowRun;动作提交失败沿用现有失败状态。 +- App 装配:两个入口共享同一个真实 `MediaApis`,没有页面级假上传实现。 + +## 非目标 + +- 不增加新的媒体模块、后端上传接口或图片生成接口。 +- 不修改 Playtest、导出格式、Cocos 或微信小程序导入。 +- 不为上传图片增加裁剪、抠图或编辑器。 diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index b65a2a5b..0c326e65 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写为只提交模块边界与接口;实现按模块拆成后续 PR 陆续落地,首页是第一个。 +本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。 --- @@ -37,12 +37,10 @@ pages -> features -> entities -> shared | `pages` | 八个路由页面 | | `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` | | `entities` | 上表业务模块 | -| `shared` | 无业务语义的分页形状、HTTP 传输与通用 UI | +| `shared` | 无业务语义的形状,目前只有分页 | `app` 只做启动和路由,不构造服务、不向下注入。 -外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。首页、快速开始、Workflow Editor 与 Playtest 使用全局外壳;`/projects/:projectId/*` 是独立项目工作区,由 `ProjectDetailPage` 提供项目级导航,不重复套全局顶栏。外壳组件自身不读 pathname,不判断自己该不该出现;顶栏内部读 pathname 只为高亮当前项。外壳也不统一夹居中容器,宽度与留白由页面自己决定。 - ### 依赖规则 1. 只能向下依赖,不允许反向。 @@ -50,8 +48,6 @@ pages -> features -> entities -> shared 3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。 4. `entities` 内部模块之间可以互相导入,对外仍是一个门。 -`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。登录模块通过 `registerApiAccessTokenProvider` 注册惰性读取函数,各业务 API 统一使用 `getApiAccessToken`;公共层只保存读取函数,不保存、刷新或解析 token。各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 - --- ## 3. 接口命名 @@ -60,6 +56,7 @@ pages -> features -> entities -> shared ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis +TaskApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 @@ -72,7 +69,8 @@ ProjectApis CharacterApis ActionTemplateApis GenerationApis `features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 -Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 +Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、 +任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 步骤顺序固定八步: @@ -80,26 +78,38 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun, +只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前 +产品流程,不是为了通用编排而写的可配置工作流。 -从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 +当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller +不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。 -快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 +快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围, +当前 Controller 只实现一次推进一个步骤。 + +Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例, +不能在组件渲染或路由切换时重复创建。 --- ## 5. 当前实现范围 -- `ProjectApis` 与 `CharacterApis` 实现 PR #75 的 Project、Character HTTP 契约;snake_case 只存在于各实体模块内部的 DTO 映射。 -- 项目中心、项目工作区、角色资产库、角色详情按 `Project → Character → Outfit → Action → Frame` 层级读取真实接口。 -- 测试通过 HTTP 替身返回契约数据;本模块的生产代码不包含 Mock API、演示实体或 livedemo 资产。 +- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验 +- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切 +- Store、Controller 和纵向流程测试 + +页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。 -本轮不包含新建项目流程、Workflow Editor 实现、Action Template 后端能力、导出接线、图片上传与登录流程。穿戴道具不作为独立资产层暴露。 +### 恢复边界 -首页仍不依赖 `entities` 与 `features`,两张入口卡片只做路由跳转。首屏三段制作路径是 `WORKFLOW_STEP_ORDER` 八步的粗粒度概括,改流程时要一并改。 +- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。 +- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力, + 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。 +- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。 --- ## 6. 未与后端对齐的部分 -PR #75 尚未合并,因此本实现要求先合并该后端 PR;契约明细与仍需后端处理的问题见 `frontend/API_CONTRACT.md`。 +明细见 `frontend/API_CONTRACT.md`。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 5814fa69..48b5e8e4 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -1,76 +1,114 @@ # 前后端接口对齐清单 -本实现以尚未合并的后端 PR #75 为目标契约,并要求按 **#75 → 本前端 PR** 的顺序合并。`upstream/main` 当前尚未挂载这些接口。 +前端各模块的 `XxxApis` 与后端 2026-07-30 接口文档逐条比对结果。 -## 一、本轮已接入 +后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 -### Project +--- -| 前端方法 | HTTP | 后端能力 | +## 一、已经确认的边界 + +- `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 +- `Character` 不使用独立 `name` 字段;前端已删除。 +- 前端保留 `jump` 动作类型,由后端补充对应枚举。 +- 查询生成任务统一携带 `projectId + taskId`。 +- 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: + +| 前端工作流节点 | 后端接口 | 后端任务类型 | |---|---|---| -| `ProjectApis.list` | `GET /projects` | `page`、`page_size`、可选 `user_id` | -| `ProjectApis.get` | `GET /projects/{id}` | 项目详情 | -| `ProjectApis.create` | `POST /projects` | 创建项目记录 | -| `ProjectApis.remove` | `DELETE /projects/{id}` | 删除项目记录 | +| `character_template` | `POST /generation/image` | `character_image` | +| `first_frame` | `POST /generation/image`,以上一步角色图作为参考图 | `character_image` | +| `complete_animation` | `POST /generation/action`,以已确认动作首帧作为参考图 | `character_action` | -`ProjectOut` 的 `user_id`、`workflow_id`、`project_name`、`character_perspective`、`directional_movement`、精灵宽高、画风、参考图和时间字段,均在 `entities/project` 内显式映射为 camelCase。PR #75 没有项目更新端点,因此前端不声明 `ProjectApis.update`。 +图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 -后端枚举按下表映射: +动作任务的 `frames[]` 会完整映射为 WorkflowRun 的 `complete_animation` 结果,保留顺序和 +`duration_ms`。审核前为满足动作生成接口的 `character_id/outfit_id` 要求,前端会创建一条 +尚无动作的角色草稿;只有审核通过后才把完整动作写回该角色。当前后端没有草稿/已发布状态, +因此资产库暂以“造型至少包含一个动作”作为已发布资产的显示条件。 -| 后端值 | `character_perspective` | `directional_movement` | -|---|---|---| -| `1` | `side` | `single` | -| `2` | `top-down` | `four-way` | -| `3` | `isometric` | `eight-way` | +--- + +## 二、前端不声明的后端缺失能力 + +后端目前没有项目更新和生成任务取消接口,前端相应地不声明 `ProjectApis.update` 或生成取消方法。创建、查询和轮询统一由 `GenerationApis` 提供。 -### Character 资产树 +--- -| 前端方法 | HTTP | 后端能力 | +## 三、形状不一致 + +这些差异可以在前端接口层转换,不要求领域类型与后端 DTO 使用相同命名。 + +| 项 | 后端 | 前端 | |---|---|---| -| `CharacterApis.listByProject` | `GET /characters?project_id=...` | 项目内角色分页列表 | -| `CharacterApis.get` | `GET /characters/{id}` | 角色详情 | -| `CharacterApis.create` | `POST /characters` | 创建空角色记录 | -| `CharacterApis.update` | `PATCH /characters/{id}` | 更新角色及完整资产树 | -| `CharacterApis.remove` | `DELETE /characters/{id}` | 删除角色及其媒体对象 | +| 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | +| 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `GenerationApis.subscribe`;适配器先立即回放当前快照,再继续轮询 | +| 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | +| 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | +| 角色视角 | `character_perspective` 为 `1~3`,文档中 2、3 都写成“正面” | `side` `top-down` `isometric` | + +ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不需要后端改动。 + +--- -后端持久化层级为: +## 四、后端有、前端没接 + +| 后端 | 说明 | +|---|---| +| `delete_character` | 前端 `CharacterApis` 没有删除 | +| `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | +| `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | + +--- + +## 五、前端资产字段在后端没有落点 + +后端 `character_data` 的嵌套结构(见 `character/model.py`): ```text -Character -└── character_data - └── outfits[] - └── actions[] - └── frames[] +outfits[] → id / name / preview_url / actions[] +actions[] → id / type / name / loop / fps / frame_count / frames[] +frames[] → index / image_url / duration_ms ``` -前端只映射后端真实字段: +前端以下字段在后端结构里没有落点: + +- `Action.kind`(preset / custom 来源) +- `Action.keyFrameIndex` +- `Frame.rootMotion` +- `Outfit.candidateCharacterTemplates`(母版候选列表) +- `Outfit.characterTemplateUrl`(每套造型的已确认角色图) +- `Outfit.baseFrames` + +`candidateCharacterTemplates` 属于生成过程数据;若只在当前 WorkflowRun 中使用,可以留在前端。其余字段若要随最终资产恢复,需要后端增加字段,或者前端在 MVP 中删除。 + +--- + +## 六、概念不一致 + +后端 `character/model.py` 字段说明: -- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` -- Outfit:`id`、`name`、`description`、`preview_url`、`actions` -- Action:`id`、`type`、`name`、`loop`、`fps`、`frame_count`、`frames` -- Frame:`index`、`image_url`、`duration_ms` +> `reference_image_url`: 角色参考图,即旧概念中的 Character Template -Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.outfitId` 仅由嵌套关系推导;修改任一子项时通过 `PATCH Character` 提交完整 `character_data`。 +前端把这两者当成不同的东西: -## 二、本轮明确不实现 +- 用户上传的参考图 —— 创建角色时的输入 +- AI 生成后用户选定的角色图(母版)—— `Outfit.characterTemplateUrl` -- 新建项目流程:只保留禁用入口,后续单独实现。 -- Workflow Editor 与生成流程:不在 Projects / 资产库模块内创建弹窗或复制生成逻辑。 -- Action Template:后端没有模块、存储或 HTTP 接口,只保留带原因的禁用入口。 -- 导出:PR #75 没有导出接口;PR #97 是尚未接入资产页的前端打包实现,只保留带原因的禁用入口。 -- 穿戴资产:当前产品定义不向用户暴露独立 Wearable 层级。 -- GIF:Character 契约只提供 Frame 图片 URL;动作卡预览使用排序后的第一帧,不伪造 GIF 字段。 +**后端合成了一个字段。** 07-30 评审也提到「模板」这个叫法容易与 action template 混淆,暂改称「角色图」。三方对这里是几个概念的理解需要统一。 -## 三、仍需后端处理 +--- -这些问题不由前端降级或伪造数据规避: +## 待确认 -1. PR #75 的 `POST /characters` DTO 接收 `name`,但路由没有把 `body.name` 传给 service;前端仍按已声明契约发送 `name`。 -2. Project / Character 路由通过 JWT,但资源查询没有按 `request.state.current_user` 强制归属隔离;前端不能代替后端完成权限边界。 -3. Project 删除没有级联 Character,可能留下孤立角色;数据一致性由后端修复。 +- [ ] 母版候选几张 +- [ ] 参考图与角色图是一个字段还是两个 +- [ ] `Character.description` 前端要不要跟着存 +- [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 -## 四、运行前置 +## 已分工 -- 配置 `VITE_API_BASE_URL`。 -- PR #75 的 Project / Character 路由要求 Bearer access token。Project、Character 实例已统一使用 `getApiAccessToken`;后续登录模块通过 `registerApiAccessTokenProvider` 提供实际 token。token 的取得、保存与刷新不属于本轮,接入前不能把未鉴权请求视为端到端可用。 -- 本模块的生产代码不包含 Mock API 或 livedemo 资产。测试只在 Vitest 中用 HTTP 服务替身验证请求、响应映射与页面行为。 +- [x] 前端删除 `WorkflowRunApis`,WorkflowRun 全程由前端管理 +- [x] 前端删除 `Character.name` +- [ ] 后端增加 `jump` 动作类型 diff --git a/frontend/ARCHITECTURE_GUARDRAILS.md b/frontend/ARCHITECTURE_GUARDRAILS.md new file mode 100644 index 00000000..ae61d68c --- /dev/null +++ b/frontend/ARCHITECTURE_GUARDRAILS.md @@ -0,0 +1,41 @@ +# Frontend Architecture Guardrails + +## Code Layers + +```text +app -> pages -> features -> entities -> shared + | + +-> workflow-controller -> entities +``` + +- `app` 只组装真实 API、共享 Controller、路由和全局外壳。 +- `pages` 负责一个路由场景,不定义后端 DTO 或第二套业务状态。 +- `features` 负责可复用的用户行为,例如审核、发布和下载包。 +- `features` 不依赖 `pages` 内部类型;需要共享的只读模型由 Feature 自己声明结构边界。 +- `workflow-controller` 是创作运行、步骤推进、Revision 和异步结果写回的唯一入口。 +- `entities` 保存领域类型、实体 API 契约及其 DTO 转换。 +- `shared` 只保存通用 HTTP、分页、UI、Hook 和工具,不能理解 Windup 业务词汇。 + +## State Ownership + +`WorkflowRun` 和 `WorkflowStep` 是 Quick Start 与 Workflow Editor 共用的唯一流程状态。画布节点只由步骤投影而来;连线和 URL 中的 `stepId` 只控制显示与聚焦,不能决定业务是否可推进。刷新恢复时,角色图与完整动作都通过同一 `GenerationApis.get/subscribe` 继续查询;完整动作结果必须保留全部帧,不能降级成首帧。 + +## Product Boundaries + +- `AssetLibrary` 展示后端已经保存的 `Character -> Outfit -> Action -> Frame` 资产树。 +- `History` 展示 `WorkflowRun` 的执行与版本记录。两者不能互相改名或合并。 +- `Review` 在创作流程中做通过或回推决定,会改变 WorkflowRun。 +- `Playtest` 只检查已发布资产,问题记录不会反向修改工作流或角色数据。 +- `Publish` 把审核通过的结果写入资产并进入 Playtest。 +- `ExportPackage` 只在 Playtest 中下载 Sprite Sheet 和清单文件,不等于发布。 + +## Backend Boundary + +页面和 Feature 不直接 `fetch`。通用传输逻辑在 `shared/api`;项目、角色、生成和媒体 DTO 分别在对应 `entities/*/api.ts` 映射。后端没有的能力不得用本地假成功、假 ID 或“跳过步骤”代替。 + +## Review Checklist + +- 是否复用了同一个 WorkflowController,而非在页面创建状态机? +- 是否区分资产库、历史、审核、预览、发布和下载? +- 是否由实体 API 隔离了后端字段与前端领域类型? +- 是否覆盖失败、刷新恢复、重复提交和终态转换? diff --git a/frontend/MODULES.md b/frontend/MODULES.md new file mode 100644 index 00000000..d18ad36c --- /dev/null +++ b/frontend/MODULES.md @@ -0,0 +1,31 @@ +# Frontend Modules + +目录层级不等于业务模块。本项目按下面六个职责模块协作,每个模块可以横跨 `pages`、`features` 和 `entities`。 + +## 1. Project Workspace + +管理项目列表、创建、详情和项目级画布约束。入口位于 `pages/projects`、`pages/project-create`、`pages/project-detail`,领域与后端转换位于 `entities/project`。 + +## 2. Character Assets + +管理已经发布的角色资产树和上传媒体。`pages/asset-library` 只展示 `Character -> Outfit -> Action -> Frame`;数据边界位于 `entities/character` 和 `entities/media`。它不是工作流历史。 + +## 3. Creation Workflow + +Quick Start 隐藏步骤自动推进,Workflow Editor 显式展示步骤;两者必须共享 `features/workflow-controller` 与 `entities/workflow-run`,不能各自维护节点状态。 + +## 4. Generation Execution + +`entities/generation` 统一表示异步生成任务,负责创建、查询和订阅。项目中不再存在重复的 `Task` 实体。Controller 负责把 Generation 结果写回正确的 WorkflowStep。 + +## 5. Review And Publishing + +`features/review` 表达审核通过或回推,`features/publish` 表达把完成版本发布为角色资产。审核改变运行状态;发布改变资产可见性,两者不是下载。 + +## 6. Playtest And Delivery + +`pages/playtest` 是只读预览与问题记录工作台。`features/export-package` 从预览模型生成 Sprite Sheet 和清单下载包;它不推进 WorkflowRun,也不替代 Publish。 + +## Supporting Layers + +`app` 统一组装路由、API 和共享 Controller;`shared` 提供不含业务含义的 HTTP、分页及通用能力。依赖规则详见 `ARCHITECTURE_GUARDRAILS.md`。 diff --git a/frontend/README.md b/frontend/README.md index 052cfc53..002bc732 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,7 @@ npm run dev npm run format:check # 格式 npm run lint # 静态检查 npm run typecheck # 类型 -npm run test # 测试 +npm run test # 单元与纵向集成测试 npm run build # 构建 ``` @@ -23,14 +23,10 @@ CI 按上面顺序全跑一遍。 ## 结构 -模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 +六个业务模块见 `MODULES.md`,代码依赖规则见 `ARCHITECTURE_GUARDRAILS.md`。 -`ProjectApis` 与 `CharacterApis` 负责业务 DTO 映射。项目中心、项目工作区、资产库与角色详情已接入 PR #75 的真实接口;测试数据只存在于测试环境的 HTTP 替身中。 - -页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。 - -`shared/api` 提供后续业务接口可复用的公共 HTTP 请求能力。 - -运行项目前需要配置 `VITE_API_BASE_URL`。Bearer token 由登录模块取得后,通过 `registerApiAccessTokenProvider` 注册读取函数;业务请求统一从该边界读取。本轮不定义 token 的保存方式。 +当前 Project、Character、Generation、Media 已接真实后端适配器。Quick Start 与 Workflow +Editor 共享同一套 `WorkflowRun` 和 Controller;项目页、资产库、历史记录与 Playtest 分别 +承担不同产品职责。Playtest 提供只读检查与独立下载包能力。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d513d30c..3c93c482 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", @@ -2202,6 +2203,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2406,6 +2424,30 @@ "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-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2525,6 +2567,13 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a845f78..5c368ad6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "tsc -b --pretty false && vite", "build": "tsc -b && vite build", "lint": "oxlint", "format": "oxfmt", @@ -25,6 +25,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "ajv": "^8.20.0", "jsdom": "^29.1.1", "oxfmt": "^0.61.0", "oxlint": "^1.71.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..ae0e455d --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2241 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.2.7 + version: 19.2.8 + react-dom: + specifier: ^19.2.7 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^8.3.0 + version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^24.13.2 + version: 24.13.3 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + oxfmt: + specifier: ^0.61.0 + version: 0.61.0 + oxlint: + specifier: ^1.71.0 + version: 1.76.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + +packages: + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@jest/types@27.0.2': + resolution: {integrity: sha512-XpjCtJ/99HB4PmyJ2vgmN7vT+JLP7RW1FBT9RgnMFS4Dt7cvIyBee8O3/j98aUZ34ZpenPZFqmaaObWSeL65dg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.10': + resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==} + + '@jridgewell/sourcemap-codec@1.4.14': + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.0': + resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^2.0.0-alpha.3 + '@emnapi/runtime': ^2.0.0-alpha.3 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@oxfmt/binding-android-arm-eabi@0.61.0': + resolution: {integrity: sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.61.0': + resolution: {integrity: sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.61.0': + resolution: {integrity: sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.61.0': + resolution: {integrity: sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.61.0': + resolution: {integrity: sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + resolution: {integrity: sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + resolution: {integrity: sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.61.0': + resolution: {integrity: sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.61.0': + resolution: {integrity: sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + resolution: {integrity: sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + resolution: {integrity: sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.61.0': + resolution: {integrity: sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.61.0': + resolution: {integrity: sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.61.0': + resolution: {integrity: sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.61.0': + resolution: {integrity: sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.61.0': + resolution: {integrity: sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.61.0': + resolution: {integrity: sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.61.0': + resolution: {integrity: sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.61.0': + resolution: {integrity: sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.76.0': + resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.76.0': + resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.76.0': + resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.76.0': + resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.76.0': + resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.76.0': + resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.76.0': + resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.76.0': + resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.76.0': + resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.76.0': + resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.76.0': + resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.76.0': + resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.76.0': + resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.76.0': + resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.76.0': + resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@16.0.11': + resolution: {integrity: sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==} + + '@vitejs/plugin-react@6.0.4': + resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.1.0: + resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} + engines: {node: '>=8'} + + ansi-styles@5.0.0: + resolution: {integrity: sha512-6564t0m0fuQMnockqBv7wJxo9T5C2V9JpYXyNScfRDPVLusOQQhkpMGrFC17QbiolraQ1sMXX+Y5nJpjqozL4g==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.0.0: + resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + enhanced-resolve@5.24.4: + resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==} + engines: {node: '>=10.13.0'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + oxfmt@0.61.0: + resolution: {integrity: sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.76.0: + resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.0.2: + resolution: {integrity: sha512-mXKbbBPnYTG7Yra9qFBtqj+IXcsvxsvOBco3QHxtxTl+hHKq6QdzMZ+q0CtL4ORHZgwGImRr2XZUX2EWzORxig==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.1: + resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==} + + react-router@8.3.0: + resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@7.1.0: + resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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 + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1': {} + + '@jest/types@27.0.2': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.13.3 + '@types/yargs': 16.0.11 + chalk: 4.0.0 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.10 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.4.10': {} + + '@jridgewell/sourcemap-codec@1.4.14': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + + '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@oxfmt/binding-android-arm-eabi@0.61.0': + optional: true + + '@oxfmt/binding-android-arm64@0.61.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.61.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.61.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.61.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.61.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.61.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.61.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.61.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.61.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.61.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.61.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.76.0': + optional: true + + '@oxlint/binding-android-arm64@1.76.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.76.0': + optional: true + + '@oxlint/binding-darwin-x64@1.76.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.76.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.76.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.76.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.76.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.76.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.76.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.76.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.76.0': + optional: true + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.4 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.0.2 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@16.0.11': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.1.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.0.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + + chalk@4.0.0: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + enhanced-resolve@5.24.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + is-potential-custom-element-name@1.0.1: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + oxfmt@0.61.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.61.0 + '@oxfmt/binding-android-arm64': 0.61.0 + '@oxfmt/binding-darwin-arm64': 0.61.0 + '@oxfmt/binding-darwin-x64': 0.61.0 + '@oxfmt/binding-freebsd-x64': 0.61.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.61.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.61.0 + '@oxfmt/binding-linux-arm64-gnu': 0.61.0 + '@oxfmt/binding-linux-arm64-musl': 0.61.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.61.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.61.0 + '@oxfmt/binding-linux-riscv64-musl': 0.61.0 + '@oxfmt/binding-linux-s390x-gnu': 0.61.0 + '@oxfmt/binding-linux-x64-gnu': 0.61.0 + '@oxfmt/binding-linux-x64-musl': 0.61.0 + '@oxfmt/binding-openharmony-arm64': 0.61.0 + '@oxfmt/binding-win32-arm64-msvc': 0.61.0 + '@oxfmt/binding-win32-ia32-msvc': 0.61.0 + '@oxfmt/binding-win32-x64-msvc': 0.61.0 + + oxlint@1.76.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.76.0 + '@oxlint/binding-android-arm64': 1.76.0 + '@oxlint/binding-darwin-arm64': 1.76.0 + '@oxlint/binding-darwin-x64': 1.76.0 + '@oxlint/binding-freebsd-x64': 1.76.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 + '@oxlint/binding-linux-arm-musleabihf': 1.76.0 + '@oxlint/binding-linux-arm64-gnu': 1.76.0 + '@oxlint/binding-linux-arm64-musl': 1.76.0 + '@oxlint/binding-linux-ppc64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-musl': 1.76.0 + '@oxlint/binding-linux-s390x-gnu': 1.76.0 + '@oxlint/binding-linux-x64-gnu': 1.76.0 + '@oxlint/binding-linux-x64-musl': 1.76.0 + '@oxlint/binding-openharmony-arm64': 1.76.0 + '@oxlint/binding-win32-arm64-msvc': 1.76.0 + '@oxlint/binding-win32-ia32-msvc': 1.76.0 + '@oxlint/binding-win32-x64-msvc': 1.76.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.0.2: + dependencies: + '@jest/types': 27.0.2 + ansi-regex: 5.0.1 + ansi-styles: 5.0.0 + react-is: 17.0.1 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.1: {} + + react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + require-from-string@2.0.2: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@7.1.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@2.1.0: {} + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + undici@7.29.0: {} + + vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@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.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/frontend/src/app/__fixtures__/character-25.json b/frontend/src/app/__fixtures__/character-25.json new file mode 100644 index 00000000..7dd37aef --- /dev/null +++ b/frontend/src/app/__fixtures__/character-25.json @@ -0,0 +1,114 @@ +{ + "code": 200, + "message": "success", + "data": { + "id": 25, + "project_id": 37, + "description": "Quick Start auto-created character", + "reference_image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "character_data": { + "version": 1, + "outfits": [ + { + "id": "outfit-25-default", + "name": "默认造型", + "description": null, + "preview_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "actions": [ + { + "id": "25-custom", + "type": "custom", + "name": "自定义动作", + "loop": false, + "fps": 8.0, + "frame_count": 16, + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/7987aecc98e2428ebcffb778361e1f48.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9d7e0b10812b471c96fbf482018e52cb.png", + "duration_ms": 125 + }, + { + "index": 2, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d35de7b47cb441b6a9a9611f2c89b9d0.png", + "duration_ms": 125 + }, + { + "index": 3, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cebef9f9c9bf459f8a033df0fe3f8df5.png", + "duration_ms": 125 + }, + { + "index": 4, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/33fd127521024445879c4e0d8bae2010.png", + "duration_ms": 125 + }, + { + "index": 5, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/88028d713cb24fbca27db0f740256296.png", + "duration_ms": 125 + }, + { + "index": 6, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/2843e05a4b114a83bf8697a2fa7775f3.png", + "duration_ms": 125 + }, + { + "index": 7, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/ef6a63d4656f4151b9bc8ce2470c3e49.png", + "duration_ms": 125 + }, + { + "index": 8, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/97ba64c6beb744f28904a15e5d7c2b70.png", + "duration_ms": 125 + }, + { + "index": 9, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/3e7f820ebb324ad68a10ccbd9106be55.png", + "duration_ms": 125 + }, + { + "index": 10, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d791896c1d4e44df953156d86b50e4a1.png", + "duration_ms": 125 + }, + { + "index": 11, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/bb2cc0c50cd24e08b13e9dcdea4a9a1c.png", + "duration_ms": 125 + }, + { + "index": 12, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/09b9029b5d88438b9b629ecdd4da0b71.png", + "duration_ms": 125 + }, + { + "index": 13, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a5b81b4bdd6c4fc09c1cffc2dae17ce9.png", + "duration_ms": 125 + }, + { + "index": 14, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1b0797d0baf249ae9d94f60b0604b740.png", + "duration_ms": 125 + }, + { + "index": 15, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1bf258a7b2384ae194425b01a4beb337.png", + "duration_ms": 125 + } + ] + } + ] + } + ] + }, + "status": 1 + } +} diff --git a/frontend/src/app/__fixtures__/character-list.json b/frontend/src/app/__fixtures__/character-list.json new file mode 100644 index 00000000..2ae3ebc2 --- /dev/null +++ b/frontend/src/app/__fixtures__/character-list.json @@ -0,0 +1,119 @@ +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 25, + "project_id": 37, + "description": "Quick Start auto-created character", + "reference_image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "character_data": { + "version": 1, + "outfits": [ + { + "id": "outfit-25-default", + "name": "默认造型", + "description": null, + "preview_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "actions": [ + { + "id": "25-custom", + "type": "custom", + "name": "自定义动作", + "loop": false, + "fps": 8.0, + "frame_count": 16, + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/7987aecc98e2428ebcffb778361e1f48.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9d7e0b10812b471c96fbf482018e52cb.png", + "duration_ms": 125 + }, + { + "index": 2, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d35de7b47cb441b6a9a9611f2c89b9d0.png", + "duration_ms": 125 + }, + { + "index": 3, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cebef9f9c9bf459f8a033df0fe3f8df5.png", + "duration_ms": 125 + }, + { + "index": 4, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/33fd127521024445879c4e0d8bae2010.png", + "duration_ms": 125 + }, + { + "index": 5, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/88028d713cb24fbca27db0f740256296.png", + "duration_ms": 125 + }, + { + "index": 6, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/2843e05a4b114a83bf8697a2fa7775f3.png", + "duration_ms": 125 + }, + { + "index": 7, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/ef6a63d4656f4151b9bc8ce2470c3e49.png", + "duration_ms": 125 + }, + { + "index": 8, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/97ba64c6beb744f28904a15e5d7c2b70.png", + "duration_ms": 125 + }, + { + "index": 9, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/3e7f820ebb324ad68a10ccbd9106be55.png", + "duration_ms": 125 + }, + { + "index": 10, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d791896c1d4e44df953156d86b50e4a1.png", + "duration_ms": 125 + }, + { + "index": 11, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/bb2cc0c50cd24e08b13e9dcdea4a9a1c.png", + "duration_ms": 125 + }, + { + "index": 12, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/09b9029b5d88438b9b629ecdd4da0b71.png", + "duration_ms": 125 + }, + { + "index": 13, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a5b81b4bdd6c4fc09c1cffc2dae17ce9.png", + "duration_ms": 125 + }, + { + "index": 14, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1b0797d0baf249ae9d94f60b0604b740.png", + "duration_ms": 125 + }, + { + "index": 15, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1bf258a7b2384ae194425b01a4beb337.png", + "duration_ms": 125 + } + ] + } + ] + } + ] + }, + "status": 1 + } + ], + "total": 1, + "page": 1, + "page_size": 20 +} diff --git a/frontend/src/app/__fixtures__/project-list.json b/frontend/src/app/__fixtures__/project-list.json new file mode 100644 index 00000000..f351cb52 --- /dev/null +++ b/frontend/src/app/__fixtures__/project-list.json @@ -0,0 +1,289 @@ +{ + "code": 200, + "message": "success", + "data": [ + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8n8o1o-3apc", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + "game_style": null, + "sprite_sample_url": null, + "id": 37, + "create_at": "2026-07-31T07:51:20.519353", + "update_at": "2026-07-31T07:51:20.519357" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8mt8iw-yr6r", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 36, + "create_at": "2026-07-31T07:39:20.564137", + "update_at": "2026-07-31T07:39:20.564142" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8ms8eg-27j1", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 35, + "create_at": "2026-07-31T07:38:33.747176", + "update_at": "2026-07-31T07:38:33.747180" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒-ms8mn50f-rdt1", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 34, + "create_at": "2026-07-31T07:34:36.075849", + "update_at": "2026-07-31T07:34:36.075856" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "会画画的猪八戒,侧视像素风,手持…-ms8mk8sc-wfj2", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 33, + "create_at": "2026-07-31T07:32:20.998825", + "update_at": "2026-07-31T07:32:20.998832" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一个会画画的猪八戒-ms8mew94-eigx", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 32, + "create_at": "2026-07-31T07:28:11.487617", + "update_at": "2026-07-31T07:28:11.487625" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一只羊-ms8md46p-e42w", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 31, + "create_at": "2026-07-31T07:26:48.450021", + "update_at": "2026-07-31T07:26:48.450028" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8m0wjc-ctrm", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 30, + "create_at": "2026-07-31T07:17:18.660575", + "update_at": "2026-07-31T07:17:18.660579" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8lu7xr-6d7o", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 29, + "create_at": "2026-07-31T07:12:06.841252", + "update_at": "2026-07-31T07:12:06.841255" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8lr4x8-3fsn", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 28, + "create_at": "2026-07-31T07:09:42.968827", + "update_at": "2026-07-31T07:09:42.968831" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "live-e2e 测试像素角色-ms8lhiv8-orai", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 27, + "create_at": "2026-07-31T07:02:14.483860", + "update_at": "2026-07-31T07:02:14.483867" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8kr027-1reg", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 26, + "create_at": "2026-07-31T06:41:37.045079", + "update_at": "2026-07-31T06:41:37.045082" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8kozxq-ik0z", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 25, + "create_at": "2026-07-31T06:40:03.578998", + "update_at": "2026-07-31T06:40:03.579002" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "verify-1785479517", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 24, + "create_at": "2026-07-31T06:31:57.337685", + "update_at": "2026-07-31T06:31:57.337693" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8jx4it-utjl", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 23, + "create_at": "2026-07-31T06:18:23.164354", + "update_at": "2026-07-31T06:18:23.164357" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8js6gp-343c", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 22, + "create_at": "2026-07-31T06:14:32.390674", + "update_at": "2026-07-31T06:14:32.390678" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8jkc4h-l12v", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 21, + "create_at": "2026-07-31T06:08:26.480760", + "update_at": "2026-07-31T06:08:26.480765" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一位提着风灯、披深色斗篷的像素守…-ms8j3vnp-ofrw", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 20, + "create_at": "2026-07-31T05:55:38.646664", + "update_at": "2026-07-31T05:55:38.646668" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "e2e-test-1785477031", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 19, + "create_at": "2026-07-31T05:50:31.377093", + "update_at": "2026-07-31T05:50:31.377096" + }, + { + "user_id": 1, + "workflow_id": null, + "project_name": "一只小狗-ms8iwqj7-rvuz", + "character_perspective": 1, + "directional_movement": 1, + "sprite_width": 64, + "sprite_height": 64, + "game_style": null, + "sprite_sample_url": null, + "id": 18, + "create_at": "2026-07-31T05:50:05.405282", + "update_at": "2026-07-31T05:50:05.405289" + } + ], + "total": 37, + "page": 1, + "page_size": 20 +} diff --git a/frontend/src/app/__fixtures__/task-70.json b/frontend/src/app/__fixtures__/task-70.json new file mode 100644 index 00000000..4e968313 --- /dev/null +++ b/frontend/src/app/__fixtures__/task-70.json @@ -0,0 +1,29 @@ +{ + "code": 200, + "message": "success", + "data": { + "id": 70, + "user_id": 1, + "project_id": 37, + "task_type": "character_image", + "status": "completed", + "input_payload": { + "reference_image_url": null, + "prompt": "会画画的猪八戒,侧视像素风,手持画笔,在画板前", + "negative_prompt": "", + "width": 256, + "height": 256, + "num_images": 4 + }, + "result": { + "type": "character_image", + "image_urls": [ + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/a5039393515b4001a04620cc99faaa2d.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/429643d53e324aa5886eb42c908c028f.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3d1c26ed7d634ef3b36ca5d6ef7b6b1c.png" + ] + }, + "error_message": null + } +} diff --git a/frontend/src/app/__fixtures__/task-71.json b/frontend/src/app/__fixtures__/task-71.json new file mode 100644 index 00000000..c4cc8163 --- /dev/null +++ b/frontend/src/app/__fixtures__/task-71.json @@ -0,0 +1,109 @@ +{ + "code": 200, + "message": "success", + "data": { + "id": 71, + "user_id": 1, + "project_id": 37, + "task_type": "character_action", + "status": "completed", + "input_payload": { + "character_id": 25, + "action_type": "custom", + "custom_prompt": "在画板上用画笔作画", + "reference_video_url": null, + "reference_image_urls": [ + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png", + "http://tio55tpsq.hd-bkt.clouddn.com/media/reference-image/3f8ce3bdb9664258b2e5dbc54c16589e.png" + ], + "num_frames": 16 + }, + "result": { + "type": "character_action", + "action_type": "custom", + "frames": [ + { + "index": 0, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/7987aecc98e2428ebcffb778361e1f48.png", + "duration_ms": 125 + }, + { + "index": 1, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/9d7e0b10812b471c96fbf482018e52cb.png", + "duration_ms": 125 + }, + { + "index": 2, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d35de7b47cb441b6a9a9611f2c89b9d0.png", + "duration_ms": 125 + }, + { + "index": 3, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/cebef9f9c9bf459f8a033df0fe3f8df5.png", + "duration_ms": 125 + }, + { + "index": 4, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/33fd127521024445879c4e0d8bae2010.png", + "duration_ms": 125 + }, + { + "index": 5, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/88028d713cb24fbca27db0f740256296.png", + "duration_ms": 125 + }, + { + "index": 6, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/2843e05a4b114a83bf8697a2fa7775f3.png", + "duration_ms": 125 + }, + { + "index": 7, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/ef6a63d4656f4151b9bc8ce2470c3e49.png", + "duration_ms": 125 + }, + { + "index": 8, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/97ba64c6beb744f28904a15e5d7c2b70.png", + "duration_ms": 125 + }, + { + "index": 9, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/3e7f820ebb324ad68a10ccbd9106be55.png", + "duration_ms": 125 + }, + { + "index": 10, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/d791896c1d4e44df953156d86b50e4a1.png", + "duration_ms": 125 + }, + { + "index": 11, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/bb2cc0c50cd24e08b13e9dcdea4a9a1c.png", + "duration_ms": 125 + }, + { + "index": 12, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/09b9029b5d88438b9b629ecdd4da0b71.png", + "duration_ms": 125 + }, + { + "index": 13, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/a5b81b4bdd6c4fc09c1cffc2dae17ce9.png", + "duration_ms": 125 + }, + { + "index": 14, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1b0797d0baf249ae9d94f60b0604b740.png", + "duration_ms": 125 + }, + { + "index": 15, + "image_url": "http://tio55tpsq.hd-bkt.clouddn.com/media/action-frame/1bf258a7b2384ae194425b01a4beb337.png", + "duration_ms": 125 + } + ] + }, + "error_message": null + } +} diff --git a/frontend/src/app/api-contract.test.ts b/frontend/src/app/api-contract.test.ts new file mode 100644 index 00000000..30bbceff --- /dev/null +++ b/frontend/src/app/api-contract.test.ts @@ -0,0 +1,94 @@ +/** + * 前后端契约测试:用真实后端响应快照(__fixtures__/)验证 adapter 解析。 + * + * 样本取自本地后端真实响应(character 25 / task 70 / project 列表)。 + * 后端 DTO 形状一旦变化,这里立刻暴露 —— 不再依赖手工联调发现。 + */ +import { describe, expect, it, vi } from 'vitest' + +import character25 from './__fixtures__/character-25.json' +import characterList from './__fixtures__/character-list.json' +import task71 from './__fixtures__/task-71.json' +import projectList from './__fixtures__/project-list.json' + +/** 信封解包(与 http-client 相同语义:返回 data 字段) */ +function unwrap(envelope: { data: T }): T { + return envelope.data +} + +vi.mock('@/shared/api', () => ({ + get: vi.fn(async (path: string) => { + if (path.startsWith('/characters?project_id')) return unwrap(characterList) + if (path.startsWith('/characters/')) return unwrap(character25) + if (path.startsWith('/generation/tasks/')) return unwrap(task71) + if (path.startsWith('/projects')) return unwrap(projectList) + throw new Error(`未收录的契约样本路径:${path}`) + }), + getPage: vi.fn(async (path: string) => { + const envelope = path.startsWith('/characters?project_id') ? characterList : projectList + return { + items: envelope.data, + total: envelope.total, + page: envelope.page, + pageSize: envelope.page_size, + } + }), + post: vi.fn(), + patch: vi.fn(), +})) + +import { createCharacterApis, createGenerationApis, createProjectApis } from '@/entities' + +describe('adapter contract (real backend snapshots)', () => { + it('character.get parses outfit, action and frames from real payload', async () => { + const apis = createCharacterApis() + const character = await apis.get('25') + + expect(character.id).toBe('25') + expect(character.projectId).toBe('37') + expect(character.outfits).toHaveLength(1) + + const outfit = character.outfits[0]! + expect(outfit.id).toBe('outfit-25-default') + expect(outfit.name).toBe('默认造型') + expect(outfit.characterTemplateUrl).toContain('reference-image') + + const action = outfit.actions[0]! + expect(action.id).toBe('25-custom') + expect(action.type).toBe('custom') + expect(action.name).toBe('自定义动作') + expect(action.frames.length).toBeGreaterThan(5) + expect(action.frames[0]!.imageUrl).toContain('action-frame') + expect(action.frames[0]!.durationMs).toBeTypeOf('number') + }) + + it('character.listByProject returns an array directly (envelope already unwrapped)', async () => { + const apis = createCharacterApis() + const characters = await apis.listByProject('37') + + expect(Array.isArray(characters)).toBe(true) + expect(characters.length).toBeGreaterThan(0) + expect(characters[0]!.outfits[0]!.id).toBe('outfit-25-default') + }) + + it('generation.get maps the backend task endpoint into one entity', async () => { + const apis = createGenerationApis() + const generation = await apis.get('37', '71') + + expect(generation.id).toBe('71') + expect(generation.projectId).toBe('37') + expect(generation.status).toBe('completed') + expect(generation.type).toBe('complete_animation') + }) + + it('project.list returns paged projects with sprite size', async () => { + const apis = createProjectApis() + const paged = await apis.list() + + expect(paged.items.length).toBeGreaterThan(0) + const project = paged.items[0]! + expect(project.spriteSize.width).toBeGreaterThan(0) + expect(project.spriteSize.height).toBeGreaterThan(0) + expect(project.id).toBeTruthy() + }) +}) diff --git a/frontend/src/app/app-composition.test.tsx b/frontend/src/app/app-composition.test.tsx new file mode 100644 index 00000000..154d9382 --- /dev/null +++ b/frontend/src/app/app-composition.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const characterApisFactory = vi.hoisted(() => + vi.fn(() => ({ + get: vi.fn(() => new Promise(() => undefined)), + listByProject: vi.fn().mockResolvedValue([]), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + })), +) +const projectApisFactory = vi.hoisted(() => + vi.fn(() => ({ + get: vi.fn(() => new Promise(() => undefined)), + list: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 100 }), + create: vi.fn(), + })), +) +const userApisFactory = vi.hoisted(() => vi.fn()) +const mediaApisFactory = vi.hoisted(() => + vi.fn(() => ({ + upload: vi.fn(), + })), +) + +vi.mock('@/entities', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCharacterApis: characterApisFactory, + createProjectApis: projectApisFactory, + createMediaApis: mediaApisFactory, + createUserApis: userApisFactory, + } +}) + +import { App } from './app' + +afterEach(() => { + cleanup() + window.history.replaceState({}, '', '/') + vi.clearAllMocks() + vi.unstubAllEnvs() +}) + +describe('App Playtest composition', () => { + it('uses local authentication by default without creating the backend auth adapter', async () => { + window.history.replaceState({}, '', '/') + + render() + + expect(userApisFactory).not.toHaveBeenCalled() + expect(await screen.findByRole('button', { name: '登录 / 注册' })).toBeTruthy() + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('让你的角色,真正登场。') + }) + + it('opens the account panel for local authentication', async () => { + window.history.replaceState({}, '', '/?account=login&returnTo=%2Fprojects') + + render() + + expect(await screen.findByRole('dialog', { name: '账户认证' })).toBeTruthy() + }) + + it('creates one shared Character and Project API instance for Playtest routes', () => { + window.history.replaceState({}, '', '/playtest/25/outfit-25-default') + + render() + + expect(characterApisFactory).toHaveBeenCalledTimes(1) + expect(projectApisFactory).toHaveBeenCalledTimes(1) + }) + + it('creates one shared Media API instance for both character-creation entries', () => { + render() + + expect(mediaApisFactory).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx new file mode 100644 index 00000000..a92f267d --- /dev/null +++ b/frontend/src/app/app.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { App } from './app' + +const user = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal' as const, +} + +const userApis = { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + login: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + loginByCode: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + refresh: vi.fn(async () => ({ accessToken: 'access', refreshToken: 'refresh', user })), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), +} + +vi.mock('@/entities', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createUserApis: vi.fn(() => userApis) } +}) + +afterEach(() => { + cleanup() + window.localStorage.clear() + window.history.replaceState({}, '', '/') + vi.unstubAllEnvs() +}) + +function authenticate() { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + window.localStorage.setItem('windup.auth.refresh-token', 'refresh') +} + +describe('App', () => { + it('provides the authentication session required by the home page', async () => { + window.history.replaceState({}, '', '/') + + render() + + expect((await screen.findByRole('heading', { level: 1 })).textContent).toBe( + '让你的角色,真正登场。', + ) + }) + + it('allows a guest to render the home page', async () => { + render() + + expect((await screen.findByRole('heading', { level: 1 })).textContent).toBe( + '让你的角色,真正登场。', + ) + }) + + it('redirects a guest quick-start visit to login', async () => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + window.history.replaceState({}, '', '/quick-start') + + render() + + await screen.findByRole('dialog', { name: '账户认证' }) + expect(window.location.search).toBe('?account=login&returnTo=%2Fquick-start') + }) + + it('keeps the new-project route ahead of the dynamic project detail route', async () => { + window.history.replaceState({}, '', '/projects/new') + authenticate() + + render() + + expect(await screen.findByRole('heading', { name: '新建项目' })).toBeTruthy() + }) + + it('将项目完成版本的入口路由到历史记录', async () => { + window.history.replaceState({}, '', '/projects/project-1/history') + authenticate() + + render() + + expect(await screen.findByRole('heading', { name: '历史记录' })).toBeTruthy() + }) + + it('keeps the asset library separate from workflow history', async () => { + window.history.replaceState({}, '', '/projects/project-1/assets') + authenticate() + + render() + + expect(await screen.findByText('正在读取项目…')).toBeTruthy() + expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull() + }) + + it('provides a dedicated Playtest entry', async () => { + window.history.replaceState({}, '', '/playtest') + authenticate() + + render() + + expect(await screen.findByRole('heading', { name: 'Playtest' })).toBeTruthy() + }) +}) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index a7c1c323..cd946b5e 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,21 +1,50 @@ +import { lazy, Suspense, useMemo } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router' +import { + createCharacterApis, + createGenerationApis, + createMediaApis, + createPlaytestInspectionApis, + createProjectApis, + createUserApis, + createWorkflowRunStore, + type UserApis, +} from '@/entities' +import { + AuthModeProvider, + ProtectedRoute, + createLocalUserApis, + resolveAuthMode, +} from '@/features/auth-session' +import { createWorkflowController } from '@/features/workflow-controller' import { AssetLibraryPage } from '@/pages/asset-library' import { CharacterDetailPage } from '@/pages/character-detail' import { HomePage } from '@/pages/home' +import { HistoryPage } from '@/pages/history' import { NotFoundPage } from '@/pages/not-found' +import { PlaytestEntryPage } from '@/pages/playtest/entry' import { PlaytestPage } from '@/pages/playtest' import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectCreatePage } from '@/pages/projects/create-page' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' -import { AppShellRoute } from './layout' +import { AppShell } from './layout' +import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick-start/service' +import { createWorkflowEditorService } from '@/pages/workflow-editor/service' + +const PlaytestDemoPage = import.meta.env.DEV + ? lazy(() => + import('@/pages/playtest/demo-page').then(({ PlaytestDemoPage }) => ({ + default: PlaytestDemoPage, + })), + ) + : null /** * 路由表与全局外壳。 - * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 - * 外壳的边界画在这张表上:首页与流程页使用全局顶栏;项目工作区使用自己的 - * 项目导航,不重复套全局外壳。 + * App 只装配一次共享接口实例,再把页面所需的最小接口集合传入对应路由。 */ export function App() { return ( @@ -25,25 +54,196 @@ export function App() { ) } -/** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */ -export function AppRoutes() { +/** 路由声明独立导出,测试可在 MemoryRouter 中验证直达地址。 */ +export function AppRoutes({ userApis }: { userApis?: UserApis } = {}) { + const authMode = resolveAuthMode() + const services = useMemo(() => { + const sharedUserApis = + userApis ?? (authMode === 'backend' ? createUserApis() : createLocalUserApis()) + const projectApis = createProjectApis() + const characterApis = createCharacterApis() + const inspectionApis = createPlaytestInspectionApis() + const generationApis = createGenerationApis() + const mediaApis = createMediaApis() + const store = createWorkflowRunStore() + const controller = createWorkflowController({ store, generationApis }) + const quickStart = createQuickStartService({ + controller, + prepareProject: createAutoPrepareProject(projectApis), + characterApis, + generationApis, + mediaApis, + }) + const workflowEditor = createWorkflowEditorService({ + controller, + confirmCandidate: (runId, selectedImageUrl) => + quickStart.confirmCandidate(runId, selectedImageUrl), + continueWithUploadedTemplate: (runId, file, actionDescription, signal) => + quickStart.continueWithUploadedTemplate(runId, file, actionDescription, signal), + getProject: (projectId) => projectApis.get(projectId), + approveReview: (runId) => quickStart.approveReview(runId), + prepareProject: async (input) => { + const project = await projectApis.create({ + name: input.projectName, + perspective: + input.view === 'topdown' + ? 'top-down' + : input.view === 'isometric' + ? 'isometric' + : 'side', + directionalMovement: + input.directions === '8' + ? 'eight-way' + : input.directions === '4' + ? 'four-way' + : 'single', + spriteSize: { width: Number(input.canvasSize), height: Number(input.canvasSize) }, + gameStyle: input.style || null, + }) + return { id: project.id, spriteSize: project.spriteSize } + }, + }) + const playtestApis = { + projects: projectApis, + characters: characterApis, + inspections: inspectionApis, + } + return { + userApis: sharedUserApis, + projectApis, + characterApis, + quickStart, + workflowEditor, + store, + playtestApis, + } + }, [authMode, userApis]) + return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - }> - } /> - } /> - } /> - - + // 本地与真实认证共用会话和页面,差异只留在这里的适配器装配。 + + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + > + } /> + } /> + } + /> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {PlaytestDemoPage ? ( + + + + } + /> + ) : null} + + + + } + /> + + + + } + /> + } /> + + + ) } diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index 33c363bc..88f7b066 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -1,14 +1,81 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { MemoryRouter } from 'react-router' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, useLocation } from 'react-router' +import type { AuthSessionValue } from '@/features/auth-session' +import { useAuthSession } from '@/features/auth-session' import { AppHeader } from './app-header' -afterEach(cleanup) +vi.mock('@/features/auth-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAuthSession: vi.fn() } +}) + +const mockedUseAuthSession = vi.mocked(useAuthSession) + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + mockedUseAuthSession.mockReturnValue({ + state: { status: 'guest', user: null }, + sendCode: vi.fn(async () => undefined), + register: vi.fn(), + login: vi.fn(), + loginByCode: vi.fn(), + changePassword: vi.fn(), + logout: vi.fn(), + } as AuthSessionValue) +}) + +function LocationProbe() { + return {useLocation().pathname} +} describe('AppHeader', () => { - it('保留三个产品入口,并将工作流路由归入创作', () => { + it('shows login and registration entry for guests', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '登录 / 注册' }).getAttribute('href')).toBe( + '/?account=login', + ) + }) + + it('shows the authenticated nickname as the account entry', () => { + mockedUseAuthSession.mockReturnValue({ + ...mockedUseAuthSession(), + state: { + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal', + }, + }, + }) + + render( + + + , + ) + + expect(screen.getByRole('link', { name: 'Ada' }).getAttribute('href')).toBe( + '/?account=settings', + ) + }) + + it('保留产品入口,并将工作流路由归入创作', () => { render( @@ -16,20 +83,53 @@ describe('AppHeader', () => { ) expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/') - expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects') expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page') - expect(screen.queryByRole('link', { name: 'Playtest' })).toBeNull() }) - it('在首页只高亮首页一项', () => { + it('在 Playtest 中随页面滚动,其余页面继续悬浮', () => { + const { container, unmount } = render( + + + , + ) + + expect(container.querySelector('header')?.className.split(' ')).toContain('relative') + expect(container.querySelector('header')?.className.split(' ')).not.toContain('fixed') + unmount() + + const projects = render( + + + , + ) + + expect(projects.container.querySelector('header')?.className.split(' ')).toContain('fixed') + }) + + it('将 Playtest 标记为独立核验工作区', () => { + render( + + + , + ) + + expect(screen.getByText('动作预览与质量核验')).toBeTruthy() + expect(screen.queryByText('项目与历史记录')).toBeNull() + }) + + it('允许从 Playtest 返回上一页', () => { render( - + + , ) - expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page') - expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull() - expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })) + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start/run-1') }) }) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 4ad4db7b..68b7e9ba 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -1,4 +1,6 @@ -import { Link, useLocation } from 'react-router' +import { Link, useLocation, useNavigate } from 'react-router' + +import { useAuthSession } from '@/features/auth-session' interface ProductNavigationItem { to: string @@ -7,12 +9,6 @@ interface ProductNavigationItem { isActive: (pathname: string) => boolean } -/** - * 三个入口对应三种去处:回首页、看已有资产、做新东西。 - * 07-31 定稿版还有一个 Playtest 项,这里没有搬——本仓库的预览路由是 - * /playtest/:characterId/:outfitId,没有角色和造型就构造不出可用地址, - * 顶栏给不出一个恒定的链接。等预览有了落地入口再加回来。 - */ const productNavigation: ProductNavigationItem[] = [ { to: '/', @@ -21,10 +17,14 @@ const productNavigation: ProductNavigationItem[] = [ }, { to: '/projects', - label: '项目资产', - compactLabel: '项目', + label: '项目', isActive: (pathname) => pathname.startsWith('/projects'), }, + { + to: '/playtest', + label: '预览台', + isActive: (pathname) => pathname.startsWith('/playtest'), + }, { to: '/quick-start', label: '创作', @@ -33,10 +33,13 @@ const productNavigation: ProductNavigationItem[] = [ }, ] -/** 左侧标牌上的第二行,随所在区域变化,让用户知道自己在哪一片。 */ function getWorkspaceLabel(pathname: string): { title: string; detail: string } { - if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) { - return { title: '项目资产', detail: '角色、造型与动作' } + if (pathname.startsWith('/playtest')) { + return { title: 'Playtest', detail: '动作预览与质量核验' } + } + + if (pathname.startsWith('/projects')) { + return { title: '项目与历史记录', detail: '角色、动作与完成版本' } } if (pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor')) { @@ -46,19 +49,39 @@ function getWorkspaceLabel(pathname: string): { title: string; detail: string } return { title: '角色资产工作台', detail: 'Windup' } } -/** - * 跨页面悬浮 Bar 知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。 - * 它读 pathname 只用于高亮当前项与切换标牌文案,不据此决定自己出不出现—— - * 谁带外壳是路由表的事,见 app.tsx。 - * 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。 - */ +/** 跨页面悬浮 Bar 知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。 */ export function AppHeader() { const { pathname } = useLocation() + const navigate = useNavigate() + const { state } = useAuthSession() const workspace = getWorkspaceLabel(pathname) + const isPlaytest = pathname.startsWith('/playtest') + const accountLabel = + state.status === 'authenticated' + ? state.user.nickname?.trim() || state.user.email.split('@')[0] || '账户' + : state.status === 'guest' + ? '登录 / 注册' + : '账户' + const accountTarget = state.status === 'authenticated' ? '/?account=settings' : '/?account=login' return ( -
+
+ {isPlaytest ? ( + + ) : null} ) })} + + {accountLabel} +
) diff --git a/frontend/src/app/layout/index.test.tsx b/frontend/src/app/layout/index.test.tsx new file mode 100644 index 00000000..468c7a37 --- /dev/null +++ b/frontend/src/app/layout/index.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router' + +import type { AuthSessionValue } from '@/features/auth-session' +import { useAuthSession } from '@/features/auth-session' +import { HomePage } from '@/pages/home' +import { AppShell } from './index' + +vi.mock('@/features/auth-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAuthSession: vi.fn() } +}) + +const mockedUseAuthSession = vi.mocked(useAuthSession) + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + const user = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal' as const, + } + const tokens = { accessToken: 'access', refreshToken: 'refresh', user } + mockedUseAuthSession.mockReset() + mockedUseAuthSession.mockReturnValue({ + state: { status: 'guest', user: null }, + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens), + login: vi.fn(async () => tokens), + loginByCode: vi.fn(async () => tokens), + changePassword: vi.fn(async () => undefined), + logout: vi.fn(async () => undefined), + } satisfies AuthSessionValue) +}) + +describe('AppShell', () => { + it.each([ + ['/', '首页'], + ['/workflow-editor/run-1', 'Workflow Editor'], + ])('为%s 使用全宽页面容器', (pathname) => { + render( + + +
页面内容
+
+
, + ) + + expect(screen.getByRole('main').className).toContain('w-full') + expect(screen.getByRole('main').className).not.toContain('max-w-5xl') + }) + + it.each(['/playtest', '/playtest/demo', '/playtest/character-1/outfit-1'])( + '在独立 Playtest 工作台 %s 中保留返回入口与产品导航', + (pathname) => { + render( + + +
Playtest 工作台
+
+
, + ) + + expect(screen.getByRole('banner')).toBeTruthy() + expect(screen.getByRole('button', { name: '返回上一页' })).toBeTruthy() + expect(screen.getByRole('link', { name: '首页' }).getAttribute('href')).toBe('/') + expect(screen.getByRole('link', { name: '项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '预览台' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: '创作' }).getAttribute('href')).toBe('/quick-start') + expect(screen.getAllByRole('main')).toHaveLength(1) + }, + ) + + it('首页账户面板将 Header 与页面统一隔离,同时让 portal dialog 留在背景之外', () => { + render( + + + + + , + ) + + const dialog = screen.getByRole('dialog', { name: '账户认证' }) + const header = screen + .getByRole('link', { name: '返回 Windup 首页', hidden: true }) + .closest('header')! + const background = header.parentElement! + const homeHeading = screen.getByRole('heading', { name: /真正登场/, hidden: true }) + + expect(background.contains(homeHeading)).toBe(true) + expect(background.getAttribute('inert')).toBe('') + expect(background.getAttribute('aria-hidden')).toBe('true') + expect(dialog.parentElement?.parentElement).toBe(document.body) + expect(dialog.closest('[inert]')).toBeNull() + + fireEvent.click(within(dialog).getByRole('button', { name: '关闭账户面板' })) + + expect(screen.queryByRole('dialog')).toBeNull() + const restoredHeader = screen.getByRole('link', { name: '返回 Windup 首页' }).closest('header')! + const restoredBackground = restoredHeader.parentElement! + expect(restoredBackground.getAttribute('inert')).toBeNull() + expect(restoredBackground.getAttribute('aria-hidden')).toBeNull() + }) +}) diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index 1ebace91..d2c9c520 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { Outlet } from 'react-router' +import { useLocation } from 'react-router' import { AppHeader } from './app-header' @@ -12,27 +12,33 @@ export interface AppShellProps { /** 全站外壳,全局导航常驻。 */ export function AppShell({ children }: AppShellProps) { - return ( -
- - {/* - 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间, - 也不按 pathname 分支给不同页面配不同容器。 - 顶栏悬浮不占布局高度,内容页的避让由 PageContainer 统一让出,满幅页面自己让。 - */} -
{children}
-
- ) -} + const { pathname, search } = useLocation() + const isPlaytestWorkspace = pathname.startsWith('/playtest') + const isWorkflowWorkspace = pathname.startsWith('/workflow-editor') + const isProjectWorkspace = + /^\/projects\/[^/]+(?:\/|$)/u.test(pathname) && pathname !== '/projects/new' + const isHomePage = pathname === '/' + const isHomeAccountOpen = isHomePage && new URLSearchParams(search).has('account') + const pageClassName = + isPlaytestWorkspace || isWorkflowWorkspace || isProjectWorkspace + ? 'w-full px-0 pb-0 pt-0' + : isHomePage + ? 'w-full' + : 'mx-auto max-w-5xl px-6 pb-8 pt-24' -/** - * 外壳的路由形态,套在一组子路由外面。 - * 哪些页面带外壳是路由决策,写在 app 的路由表里;外壳自身不读 pathname、不判断自己该不该出现。 - */ -export function AppShellRoute() { return ( - - - +
+ {/* Playtest 保留产品导航;workflow-editor 和项目详情使用各自的工作台导航。 */} + {!isWorkflowWorkspace && !isProjectWorkspace && } + {isPlaytestWorkspace ? ( +
{children}
+ ) : ( +
{children}
+ )} +
) } diff --git a/frontend/src/architecture-boundaries.test.ts b/frontend/src/architecture-boundaries.test.ts new file mode 100644 index 00000000..65511736 --- /dev/null +++ b/frontend/src/architecture-boundaries.test.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SRC = join(process.cwd(), 'src') + +function sourceFiles(root: string): string[] { + return readdirSync(root).flatMap((name) => { + const path = join(root, name) + return statSync(path).isDirectory() ? sourceFiles(path) : /\.(ts|tsx)$/.test(name) ? [path] : [] + }) +} + +describe('frontend architecture boundaries', () => { + it('keeps removed duplicate models and page-local workflow state out of the tree', () => { + const removed = [ + 'app/adapters/index.ts', + 'entities/task/index.ts', + 'entities/action-template/index.ts', + 'pages/workflow-editor/state.ts', + 'pages/workflow-editor/types.ts', + ] + + expect(removed.filter((path) => existsSync(join(SRC, path)))).toEqual([]) + }) + + it('keeps shared independent from Windup business layers', () => { + const violations = sourceFiles(join(SRC, 'shared')).filter((path) => { + const source = readFileSync(path, 'utf8') + return /from ['"]@\/(app|pages|features|entities)\b/.test(source) + }) + + expect(violations.map((path) => relative(SRC, path))).toEqual([]) + }) + + it('keeps transport calls behind shared api and entity adapters', () => { + const violations = sourceFiles(join(SRC, 'pages')).filter((path) => + /\bfetch\s*\(/.test(readFileSync(path, 'utf8')), + ) + + expect(violations.map((path) => relative(SRC, path))).toEqual([]) + }) + + it('keeps reusable features independent from page implementations', () => { + const violations = sourceFiles(join(SRC, 'features')).filter((path) => + /from ['"]@\/pages\b/.test(readFileSync(path, 'utf8')), + ) + + expect(violations.map((path) => relative(SRC, path))).toEqual([]) + }) +}) diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts deleted file mode 100644 index 2dafa39c..00000000 --- a/frontend/src/entities/action-template/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -interface ActionTemplateBase { - id: string - name: string - prompt: string -} - -/** 系统内置模板没有项目归属;项目自定义模板必须携带所属 Project ID。 */ -export type ActionTemplate = ActionTemplateBase & - ({ scope: 'system'; projectId: null } | { scope: 'project'; projectId: string }) - -/** ActionTemplate 对应的一组后端接口。 */ -export interface ActionTemplateApis { - listAvailable(projectId: string): Promise -} diff --git a/frontend/src/entities/character/api.test.ts b/frontend/src/entities/character/api.test.ts new file mode 100644 index 00000000..a507e302 --- /dev/null +++ b/frontend/src/entities/character/api.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createCharacterApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('character API adapter', () => { + it('preserves action playback metadata when saving the complete character tree', async () => { + const backendCharacter = { + id: 25, + project_id: 3, + description: null, + reference_image_url: null, + status: 1, + character_data: { + version: 1, + outfits: [ + { + id: 'outfit-default', + name: 'Default', + description: null, + preview_url: null, + actions: [ + { + id: 'idle', + type: 'idle', + name: 'Idle', + loop: true, + fps: 8, + frame_count: 1, + frames: [ + { index: 0, image_url: '/idle-0.png', duration_ms: 125, root_motion: null }, + ], + }, + ], + }, + ], + }, + } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(backendCharacter)) + .mockResolvedValueOnce(jsonResponse(backendCharacter)) + vi.stubGlobal('fetch', fetchMock) + + const apis = createCharacterApis() + const character = await apis.get('25') + await apis.update(character) + + expect(character.outfits[0]?.actions[0]?.loop).toBe(true) + expect(character.outfits[0]?.actions[0]?.expectedFrameCount).toBe(1) + const updateRequest = fetchMock.mock.calls[1]?.[1] as RequestInit + const updateBody = JSON.parse(String(updateRequest.body)) as { + character_data: { + outfits: Array<{ actions: Array<{ loop: boolean; frame_count: number }> }> + } + } + expect(updateBody.character_data.outfits[0]?.actions[0]?.loop).toBe(true) + expect(updateBody.character_data.outfits[0]?.actions[0]?.frame_count).toBe(1) + }) + + it('loads every character page for a project instead of truncating after 100 items', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + listResponse( + Array.from({ length: 100 }, (_, index) => character(index + 1)), + 101, + 1, + 100, + ), + ) + .mockResolvedValueOnce(listResponse([character(101)], 101, 2, 100)) + vi.stubGlobal('fetch', fetchMock) + + const result = await createCharacterApis().listByProject('3') + + expect(result).toHaveLength(101) + expect(result[0]?.id).toBe('1') + expect(result[100]?.id).toBe('101') + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'http://127.0.0.1:8000/characters?project_id=3&page=1&page_size=100', + expect.any(Object), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://127.0.0.1:8000/characters?project_id=3&page=2&page_size=100', + expect.any(Object), + ) + }) +}) + +function character(id: number) { + return { + id, + project_id: 3, + description: null, + reference_image_url: null, + status: 1, + character_data: { version: 1, outfits: [] }, + } +} + +function listResponse(data: unknown[], total: number, page: number, pageSize: number) { + return new Response( + JSON.stringify({ code: 200, message: 'success', data, total, page, page_size: pageSize }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) +} + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/character/api.ts b/frontend/src/entities/character/api.ts new file mode 100644 index 00000000..d3159ebe --- /dev/null +++ b/frontend/src/entities/character/api.ts @@ -0,0 +1,202 @@ +import type { + Action, + ActionType, + Character, + CharacterApis, + CreateCharacterInput, + Frame, + Outfit, +} from '.' + +import { del, get, getPage, patch, post } from '@/shared/api' +import type { Paged, PageQuery } from '@/shared/pagination' + +/* ─── 后端 DTO ─── */ + +interface BackendFrame { + index: number + image_url: string + duration_ms: number | null + root_motion?: { dx: number; dy: number } | null +} + +interface BackendAction { + id: string + type: string + name: string + loop: boolean + fps: number + frame_count: number + frames: BackendFrame[] +} + +interface BackendOutfit { + id: string + name: string + description: string | null + preview_url: string | null + actions: BackendAction[] +} + +interface BackendCharacterData { + version: number + outfits: BackendOutfit[] +} + +interface BackendCharacter { + id: number + project_id: number + name?: string | null + description: string | null + reference_image_url: string | null + character_data: BackendCharacterData + status: number + create_at?: string + update_at?: string +} + +/* ─── 映射 ─── */ + +const ACTION_TYPE_SET = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) + +function toActionType(raw: string): ActionType { + return ACTION_TYPE_SET.has(raw) ? (raw as ActionType) : 'custom' +} + +function toFrame(raw: BackendFrame): Frame { + return { + imageUrl: raw.image_url, + durationMs: raw.duration_ms, + rootMotion: raw.root_motion ?? null, + } +} + +function toAction(raw: BackendAction, outfitId: string): Action { + return { + id: raw.id, + outfitId, + name: raw.name, + expectedFrameCount: raw.frame_count, + loop: raw.loop, + kind: 'custom', // 后端不区分 preset/custom + type: toActionType(raw.type), + fps: raw.fps, + keyFrameIndex: null, // 后端不提供关键帧索引 + frames: raw.frames.sort((a, b) => a.index - b.index).map(toFrame), + } +} + +function toOutfit(raw: BackendOutfit, characterId: string): Outfit { + return { + id: raw.id, + characterId, + name: raw.name, + description: raw.description, + candidateCharacterTemplates: [], // 后端 character_data 不含候选 + characterTemplateUrl: raw.preview_url, + baseFrames: [], + actions: raw.actions.map((a) => toAction(a, raw.id)), + } +} + +function toCharacter(raw: BackendCharacter): Character { + const id = String(raw.id) + return { + id, + projectId: String(raw.project_id), + name: raw.name ?? null, + description: raw.description, + referenceImageUrl: raw.reference_image_url, + dataVersion: raw.character_data?.version ?? 1, + status: raw.status, + createdAt: raw.create_at ?? '', + updatedAt: raw.update_at ?? '', + outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)), + } +} + +/* ─── 适配器 ─── */ + +export function createCharacterApis(): CharacterApis { + async function listPageByProject( + projectId: string, + query: PageQuery = {}, + ): Promise> { + const params = new URLSearchParams({ project_id: projectId }) + if (query.page) params.set('page', String(query.page)) + if (query.pageSize) params.set('page_size', String(query.pageSize)) + const page = await getPage(`/characters?${params}`) + return { ...page, items: page.items.map(toCharacter) } + } + + return { + async get(id: string): Promise { + const raw = await get(`/characters/${id}`) + return toCharacter(raw) + }, + + async listByProject(projectId: string): Promise { + const items: Character[] = [] + let pageNumber = 1 + for (;;) { + const page = await listPageByProject(projectId, { page: pageNumber, pageSize: 100 }) + items.push(...page.items) + if (items.length >= page.total || page.items.length === 0) break + pageNumber += 1 + } + return items + }, + + listPageByProject, + + async create(input: CreateCharacterInput): Promise { + const raw = await post('/characters', { + project_id: Number(input.projectId), + name: input.name ?? null, + description: input.description, + reference_image_url: input.referenceImageUrl ?? null, + }) + return toCharacter(raw) + }, + + async update(character: Character): Promise { + const payload = { + name: character.name ?? null, + description: character.description ?? null, + reference_image_url: character.referenceImageUrl ?? null, + character_data: { + version: character.dataVersion ?? 1, + outfits: character.outfits.map((outfit) => ({ + id: outfit.id, + name: outfit.name, + description: outfit.description ?? null, + preview_url: outfit.characterTemplateUrl, + actions: outfit.actions.map((action) => ({ + id: action.id, + type: action.type, + name: action.name, + loop: action.loop ?? false, + fps: action.fps, + frame_count: action.expectedFrameCount ?? action.frames.length, + frames: action.frames.map((frame, index) => ({ + index, + image_url: frame.imageUrl, + duration_ms: frame.durationMs, + })), + })), + })), + }, + } + const raw = await patch(`/characters/${character.id}`, payload) + const saved = toCharacter(raw) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + return saved + }, + + async remove(id: string): Promise { + await del(`/characters/${id}`) + }, + } +} diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index af0a21de..2c04180b 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -51,7 +51,7 @@ afterEach(() => { async function loadCharacterApis(fetchFn: typeof fetch) { vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', fetchFn) - return (await import('./index')).characterApis + return (await import('./api')).createCharacterApis() } function jsonResponse(data: unknown) { @@ -78,10 +78,10 @@ describe('characterApis', () => { ) }) - const page = await characterApis.listByProject('42', { page: 1, pageSize: 20 }) + const page = await characterApis.listPageByProject!('42', { page: 1, pageSize: 20 }) expect(requestUrl).toBe('https://api.windup.test/characters?project_id=42&page=1&page_size=20') - expect(page).toEqual({ + expect(page).toMatchObject({ items: [ { id: '51', @@ -97,7 +97,7 @@ describe('characterApis', () => { characterId: '51', name: '常态造型', description: '旅行装束', - previewUrl: 'https://cdn.windup.test/outfit.png', + characterTemplateUrl: 'https://cdn.windup.test/outfit.png', actions: [ { id: 'walk', @@ -106,17 +106,16 @@ describe('characterApis', () => { name: '行走', loop: true, fps: 10, - frameCount: 2, frames: [ { - index: 1, - imageUrl: 'https://cdn.windup.test/walk-02.png', - durationMs: 120, - }, - { - index: 0, imageUrl: 'https://cdn.windup.test/walk-01.png', durationMs: null, + rootMotion: null, + }, + { + imageUrl: 'https://cdn.windup.test/walk-02.png', + durationMs: 120, + rootMotion: null, }, ], }, @@ -199,7 +198,19 @@ describe('characterApis', () => { reference_image_url: 'https://cdn.windup.test/reference.png', character_data: { version: 2, - outfits: characterDto.character_data.outfits, + outfits: [ + { + ...characterDto.character_data.outfits[0], + actions: [ + { + ...characterDto.character_data.outfits[0]!.actions[0], + frames: [...characterDto.character_data.outfits[0]!.actions[0]!.frames].sort( + (left, right) => left.index - right.index, + ), + }, + ], + }, + ], }, }) }) diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 43c2cf6c..12f623d6 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -1,250 +1,161 @@ -import { createApiClient, getApiAccessToken } from '@/shared/api' -import type { Paged, PageQuery } from '@/shared/pagination' +/** + * 动作「如何被定义」的来源维度:preset 复用预设定义,custom 由用户自定义。 + * 它与动作做什么的 ActionType 相互独立,例如 custom + walk 和 preset + custom 都是合法组合。 + */ +export type ActionKind = 'preset' | 'custom' + +/** + * 动作「做什么」的业务语义维度;custom 表示不属于当前内置语义枚举。 + * 它不表示定义来源:custom 来源仍可描述 walk,preset 来源也可承载 custom 业务语义。 + */ +export type ActionType = 'walk' | 'idle' | 'attack' | 'jump' | 'custom' -/** PR #75 将动作类型定义为字符串;已知类型之外的后端扩展也应原样保留。 */ -export type ActionType = string +/** 单帧相对动作首帧的根位移,单位为像素。 */ +export interface FrameRootMotion { + dx: number + /** 正值表示向上。 */ + dy: number +} +/** + * 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 + * + * 不带任何审核字段:服务端只交付生成好的帧,不返回质检结论;用户侧的审核也只是查看, + * 没有打回。前端若要做自动质检,那是读取帧之后在本地算出来的临时结论,不属于资产数据。 + */ export interface Frame { - /** 使用后端显式返回的帧序号,不用数组下标替代。 */ - index: number imageUrl: string - /** null 时才按所属 Action.fps 计算等时长。 */ + /** + * 此帧的显示时长,单位毫秒。 + * null 表示该帧没有独立时长,读取方才使用所属 Action.fps 计算等时长回退值。 + */ durationMs: number | null + /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ + rootMotion: FrameRootMotion | null +} + +/** 一张母版候选;attemptId 用于区分候选所属的生成尝试。 */ +export interface CharacterTemplateCandidate { + id: string + imageUrl: string + attemptId: string +} + +/** 确认母版候选时同时命名造型与候选,避免两个字符串 ID 在调用处颠倒。 */ +export interface ConfirmCharacterTemplateInput { + outfitId: string + candidateId: string +} + +/** 造型的基础参考帧;方向与质检结构等待真实资产契约后再扩展。 */ +export interface BaseFrame { + readonly imageUrl: string } +/** 某个角色造型下的一段动画动作。 */ export interface Action { - /** Action 只在所属 Outfit 内唯一。 */ + /** + * 仅在所属 Outfit 内唯一。动作没有自己的表,整棵树存在 character 记录里, + * 因此不存在全局唯一的动作 ID:任何按 ID 定位动作的地方都必须同时带上造型。 + */ id: string outfitId: Outfit['id'] name: string + /** + * 生成端声明的完整帧数。旧的纯前端数据可以暂时缺省,但正式后端数据必须保留该值, + * 否则 Playtest 不能判断收到的 frames 是否完整。 + */ + expectedFrameCount?: number + /** 是否在播放到末帧后从首帧继续;整树更新时必须原样保存。 */ + loop?: boolean + /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ + kind: ActionKind + /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ type: ActionType - loop: boolean + /** + * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; + * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 + */ fps: number - frameCount: number + /** + * 攻击触点、跳跃顶点等关键时刻在 frames 中的零基下标;null 表示没有明确关键帧。 + * 非 null 值必须指向当前 frames 数组内的成员。 + */ + keyFrameIndex: number | null + /** + * 按播放顺序排列的帧;数组下标就是零基帧序号。 + * 当前只表达单朝向。Project.directionalMovement 的四向/八向要如何落到这里 + * (本层再分组,还是一个朝向一条 Action)尚未有产品定义,不要凭猜先定结构。 + */ frames: Frame[] } +/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ export interface Outfit { - /** Outfit 只在所属 Character 内唯一。 */ + /** + * 仅在所属 Character 内唯一。造型没有自己的表,与动作一起存在 character 记录里, + * 因此不存在全局唯一的造型 ID:任何按 ID 定位造型的地方都必须同时带上角色。 + */ id: string - characterId: Character['id'] + characterId: string name: string - description: string | null - previewUrl: string | null + /** 造型说明来自 character_data;旧资产没有时为 null。 */ + description?: string | null + /** 母版生成阶段返回的候选;生成完成前可以为空数组。 */ + candidateCharacterTemplates: CharacterTemplateCandidate[] + /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */ + characterTemplateUrl: string | null + /** 供后续动作生成使用的只读基础帧入口。 */ + readonly baseFrames: readonly BaseFrame[] + /** 每个 Action.outfitId 必须等于本造型 ID。 */ actions: Action[] } -/** 项目下的角色资产;造型、动作与帧来自同一份 character_data。 */ +/** + * 项目下的角色资产;造型拥有各自的母版和动作帧。 + * + * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 + * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 + */ export interface Character { id: string projectId: string - name: string | null - description: string | null - referenceImageUrl: string | null - /** character_data.version,更新整棵资产树时必须原样带回。 */ - dataVersion: number - status: number + /** 当前后端返回时用于资产库展示;旧记录没有时为空。 */ + name?: string | null + /** 角色描述与参考图属于 Character 顶层后端字段。 */ + description?: string | null + referenceImageUrl?: string | null + /** character_data.version,整棵更新时原样带回。 */ + dataVersion?: number + /** 后端记录状态;当前 1 表示正常。 */ + status?: number + /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ outfits: Outfit[] + createdAt: string + updatedAt: string } -/** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */ +/** 创建角色并发起母版生成所需的入参。 */ export interface CreateCharacterInput { projectId: string name?: string | null - description?: string | null + /** 交给模型生成母版。 */ + description: string referenceImageUrl?: string | null } /** * Character 对应的一组后端接口。 - * Outfit、Action、Frame 没有独立端点,更新时随 Character 整棵提交。 + * 造型、动作和帧是 Character 内的完整树,不通过独立粒度方法写入;每次确认后整棵更新。 */ export interface CharacterApis { get(id: Character['id']): Promise - listByProject(projectId: string, query?: PageQuery): Promise> + listByProject(projectId: string): Promise + listPageByProject?( + projectId: string, + query?: import('@/shared/pagination').PageQuery, + ): Promise> create(input: CreateCharacterInput): Promise update(character: Character): Promise remove(id: Character['id']): Promise } - -interface CharacterFrameDto { - index: number - image_url: string - duration_ms: number | null -} - -interface CharacterActionDto { - id: string - type: string - name: string - loop: boolean - fps: number - frame_count: number - frames: CharacterFrameDto[] -} - -interface CharacterOutfitDto { - id: string - name: string - description: string | null - preview_url: string | null - actions: CharacterActionDto[] -} - -interface CharacterDataDto { - version: number - outfits: CharacterOutfitDto[] -} - -interface CharacterDto { - id: number - project_id: number - name: string | null - description: string | null - reference_image_url: string | null - character_data: CharacterDataDto - status: number -} - -function toBackendId(value: string, field: string): number { - const parsed = Number(value) - if (Number.isSafeInteger(parsed) && parsed > 0) return parsed - throw new TypeError(`${field} 必须是正整数 ID`) -} - -function mapFrame(dto: CharacterFrameDto): Frame { - return { - index: dto.index, - imageUrl: dto.image_url, - durationMs: dto.duration_ms, - } -} - -function mapAction(dto: CharacterActionDto, outfitId: string): Action { - return { - id: dto.id, - outfitId, - name: dto.name, - type: dto.type, - loop: dto.loop, - fps: dto.fps, - frameCount: dto.frame_count, - frames: dto.frames.map(mapFrame), - } -} - -function mapOutfit(dto: CharacterOutfitDto, characterId: string): Outfit { - return { - id: dto.id, - characterId, - name: dto.name, - description: dto.description, - previewUrl: dto.preview_url, - actions: dto.actions.map((action) => mapAction(action, dto.id)), - } -} - -function mapCharacter(dto: CharacterDto): Character { - const characterId = String(dto.id) - return { - id: characterId, - projectId: String(dto.project_id), - name: dto.name, - description: dto.description, - referenceImageUrl: dto.reference_image_url, - dataVersion: dto.character_data.version, - status: dto.status, - outfits: dto.character_data.outfits.map((outfit) => mapOutfit(outfit, characterId)), - } -} - -function toFrameDto(frame: Frame): CharacterFrameDto { - return { - index: frame.index, - image_url: frame.imageUrl, - duration_ms: frame.durationMs, - } -} - -function toActionDto(action: Action): CharacterActionDto { - return { - id: action.id, - type: action.type, - name: action.name, - loop: action.loop, - fps: action.fps, - frame_count: action.frameCount, - frames: action.frames.map(toFrameDto), - } -} - -function toOutfitDto(outfit: Outfit): CharacterOutfitDto { - return { - id: outfit.id, - name: outfit.name, - description: outfit.description, - preview_url: outfit.previewUrl, - actions: outfit.actions.map(toActionDto), - } -} - -function getApiClient() { - return createApiClient({ getAccessToken: getApiAccessToken }) -} - -export const characterApis: CharacterApis = { - async get(id) { - return mapCharacter( - await getApiClient().request(`/characters/${encodeURIComponent(id)}`), - ) - }, - - async listByProject(projectId, query = {}) { - const result = await getApiClient().requestList('/characters', { - query: { - project_id: toBackendId(projectId, 'projectId'), - page: query.page, - page_size: query.pageSize, - }, - }) - return { ...result, items: result.items.map(mapCharacter) } - }, - - async create(input) { - const dto = await getApiClient().request('/characters', { - method: 'POST', - json: { - project_id: toBackendId(input.projectId, 'projectId'), - name: input.name, - description: input.description, - reference_image_url: input.referenceImageUrl, - }, - }) - return mapCharacter(dto) - }, - - async update(character) { - const dto = await getApiClient().request( - `/characters/${encodeURIComponent(character.id)}`, - { - method: 'PATCH', - json: { - name: character.name, - description: character.description, - reference_image_url: character.referenceImageUrl, - character_data: { - version: character.dataVersion, - outfits: character.outfits.map(toOutfitDto), - }, - }, - }, - ) - return mapCharacter(dto) - }, - - async remove(id) { - await getApiClient().request(`/characters/${encodeURIComponent(id)}`, { - method: 'DELETE', - }) - }, -} diff --git a/frontend/src/entities/generation/api.test.ts b/frontend/src/entities/generation/api.test.ts new file mode 100644 index 00000000..ba58c6e8 --- /dev/null +++ b/frontend/src/entities/generation/api.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { MediaReference } from '../media' +import { createGenerationApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function generationTaskResponse() { + return new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: { + id: 11, + user_id: 1, + project_id: 7, + task_type: 'character_action', + status: 'pending', + input_payload: {}, + result: null, + error_message: null, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) +} + +describe('generation API adapter', () => { + it('requests 32 frames for a complete animation while keeping first-frame generation at one frame', async () => { + const fetchMock = vi.fn().mockImplementation(async () => generationTaskResponse()) + vi.stubGlobal('fetch', fetchMock) + const api = createGenerationApis() + + await api.create({ + type: 'first_frame', + projectId: '7', + characterId: '9', + outfitId: 'outfit-9-default', + actionType: 'walk', + prompt: null, + referenceMedia: [], + }) + await api.create({ + type: 'complete_animation', + projectId: '7', + characterId: '9', + outfitId: 'outfit-9-default', + actionType: 'walk', + firstFrameUrl: 'https://cdn.example.com/first-frame.png', + prompt: null, + referenceMedia: ['media-reference-1' as MediaReference], + }) + + const firstFramePayload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record< + string, + unknown + > + const completeAnimationPayload = JSON.parse( + String(fetchMock.mock.calls[1]?.[1]?.body), + ) as Record + expect(firstFramePayload.num_frames).toBe(1) + expect(completeAnimationPayload.num_frames).toBe(32) + }) +}) diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts new file mode 100644 index 00000000..012608d4 --- /dev/null +++ b/frontend/src/entities/generation/api.ts @@ -0,0 +1,210 @@ +import { + COMPLETE_ANIMATION_FRAME_COUNT, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type GenerationType, +} from '.' + +import { get, post } from '@/shared/api' +import { subscribeToEventStream } from '@/shared/api/stream' + +/* ─── 后端 DTO ─── */ + +interface BackendGenerationTask { + id: number + user_id: number + project_id: number + task_type: string + status: string + input_payload: Record + result: unknown + error_message: string | null +} + +/* ─── 映射 ─── */ + +const STATUS_MAP: Record = { + pending: 'pending', + running: 'running', + completed: 'completed', + failed: 'failed', +} + +const GENERATION_TYPE_MAP: Record = { + character_image: 'character_template', + character_template: 'character_template', + character_action: 'complete_animation', + first_frame: 'first_frame', + complete_animation: 'complete_animation', +} + +function toGeneration( + raw: BackendGenerationTask, + expectedType?: T, +): Generation { + const type = expectedType ?? ((GENERATION_TYPE_MAP[raw.task_type] ?? raw.task_type) as T) + return { + id: String(raw.id), + projectId: String(raw.project_id), + type, + status: STATUS_MAP[raw.status] ?? 'pending', + result: toGenerationResult(type, raw.result), + error: raw.error_message, + } +} + +function toGenerationResult(type: GenerationType, value: unknown): Generation['result'] { + if (!value || typeof value !== 'object') return null + if (type === 'character_template') { + const result = value as { image_urls?: unknown } + return Array.isArray(result.image_urls) + ? { + type: 'character_template', + images: result.image_urls + .filter((url): url is string => typeof url === 'string' && url.length > 0) + .map((url) => ({ url })), + } + : null + } + + const action = value as { + action_type?: unknown + frames?: readonly { index?: number; image_url?: unknown; duration_ms?: unknown }[] + } + const frames = Array.isArray(action.frames) + ? [...action.frames] + .sort((left, right) => (left.index ?? 0) - (right.index ?? 0)) + .filter((frame) => typeof frame.image_url === 'string' && frame.image_url.length > 0) + .map((frame) => ({ + url: frame.image_url as string, + durationMs: typeof frame.duration_ms === 'number' ? frame.duration_ms : null, + })) + : [] + if (frames.length === 0) return null + if (type === 'first_frame') return { type: 'first_frame', image: frames[0]! } + + const knownTypes = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) + return { + type: 'complete_animation', + actionType: + typeof action.action_type === 'string' && knownTypes.has(action.action_type) + ? (action.action_type as 'walk' | 'idle' | 'attack' | 'jump' | 'custom') + : 'custom', + frames, + } +} + +/* ─── 输入 → 后端请求体 ─── */ + +function toBackendPayload(input: GenerationInput, userId: number) { + if (input.type === 'character_template') { + return { + user_id: userId, + project_id: Number(input.projectId), + prompt: input.prompt, + reference_image_url: input.referenceMedia[0] ?? null, + width: input.spriteWidth, + height: input.spriteHeight, + num_images: 4, + } + } + + if (input.type === 'first_frame') { + return { + user_id: userId, + project_id: Number(input.projectId), + character_id: Number(input.characterId), + action_type: input.actionType, + custom_prompt: input.prompt, + reference_image_urls: input.referenceMedia.map(String), + num_frames: 1, + } + } + + // complete_animation + return { + user_id: userId, + project_id: Number(input.projectId), + character_id: Number(input.characterId), + action_type: input.actionType, + custom_prompt: input.prompt, + reference_image_urls: [input.firstFrameUrl, ...input.referenceMedia.map(String)], + num_frames: COMPLETE_ANIMATION_FRAME_COUNT, + } +} + +/* ─── 适配器 ─── */ + +const GENERATION_ENDPOINTS: Record = { + character_template: '/generation/image', + first_frame: '/generation/action', + complete_animation: '/generation/action', +} + +function streamUrl(projectId: string, id: string) { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8000' + return `${baseUrl.replace(/\/$/u, '')}/generation/tasks/${encodeURIComponent(id)}/stream?project_id=${encodeURIComponent(projectId)}` +} + +function parseTaskUpdate(data: string): GenerationEvent { + let value: unknown + try { + value = JSON.parse(data) as unknown + } catch (cause) { + throw new Error('task_update 不是有效 JSON', { cause }) + } + if (!value || typeof value !== 'object') throw new Error('task_update 不是对象') + const event = value as Record + const taskId = event.task_id + const taskType = event.task_type + const status = event.status + if ((typeof taskId !== 'string' && typeof taskId !== 'number') || typeof taskType !== 'string') { + throw new Error('task_update 缺少任务标识或类型') + } + if (typeof status !== 'string' || !(status in STATUS_MAP)) { + throw new Error('task_update 状态无效') + } + const type = GENERATION_TYPE_MAP[taskType] ?? 'complete_animation' + return { + taskId: String(taskId), + type, + status: STATUS_MAP[status]!, + result: toGenerationResult(type, event.result), + error: typeof event.error_message === 'string' ? event.error_message : null, + } +} + +export function createGenerationApis(): GenerationApis { + return { + async create(input: T): Promise> { + const endpoint = GENERATION_ENDPOINTS[input.type] + if (!endpoint) throw new Error(`未知的生成类型:${input.type}`) + + const payload = toBackendPayload(input, 1) // TODO: 接入认证后替换 userId + const raw = await post(endpoint, payload) + return toGeneration(raw, input.type) + }, + + async get(projectId: string, id: string): Promise { + const raw = await get( + `/generation/tasks/${id}?project_id=${encodeURIComponent(projectId)}`, + ) + return toGeneration(raw) + }, + + subscribe(projectId, id, onEvent, onError = () => undefined) { + return subscribeToEventStream(streamUrl(projectId, id), { + eventName: 'task_update', + onEvent(data) { + const event = parseTaskUpdate(data) + if (event.taskId !== id) throw new Error('task_update 与订阅任务不一致') + onEvent(event) + return event.status === 'completed' || event.status === 'failed' + }, + onError, + }) + }, + } +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d4..16b47030 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -4,26 +4,17 @@ import type { MediaReference } from '../media' /** * Generation 是业务数据,不是「调用图片生成能力」。 * 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。 - * - * 后端只有 GenerationTask 一个实体,generation 与 task 指同一条记录; - * `/generation/tasks/{task_id}` 里的 tasks 只是路径段,前端不为它另立实体。 - */ - -/** - * 后端 GenerationTask.status,与 WorkflowRevision.generationStatus 不是一回事: - * 这里是单次生成任务的状态,那里是一个版本在生成阶段的汇总状态。 - * pending 表示已提交但尚未执行。 */ -export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' -/** - * 生成对应的三个前端可见异步步骤。 - * 它是前端工作流粒度,不等于后端 task_type——后端只有 character_image 与 - * character_action 两种,character_template 和 first_frame 都落在 character_image 上。 - * 完整动画内部可含视频生成、截帧和多次图像处理,但对前端仍是一次 Generation。 - */ +/** 生成对应的三个前端可见异步步骤。 */ export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' +/** 完整动作默认生成帧数;首帧生成仍固定为 1 帧。 */ +export const COMPLETE_ANIMATION_FRAME_COUNT = 32 + +/** 后端单次生成任务的生命周期。 */ +export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed' + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ @@ -35,6 +26,10 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */ + spriteWidth: number + /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */ + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ @@ -74,15 +69,84 @@ export interface CharacterTemplateGenerationResult { images: readonly GeneratedImage[] } +/** + * Generation.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 + * + * 兼容后端两种返回格式: + * - 旧版单图:`{ type, image_url: "..." }` + * - 新版多图:`{ type, image_urls: ["...", "..."] }` + */ +export function parseCharacterTemplateGenerationResult( + value: unknown, +): CharacterTemplateGenerationResult | null { + if ( + !isRecord(value) || + (value.type !== 'character_template' && value.type !== 'character_image') + ) { + return null + } + + // 优先使用 image_urls(多图),兼容 image_url(单图) + const rawUrls: string[] = [] + if (Array.isArray(value.image_urls)) { + for (const item of value.image_urls) { + if (typeof item === 'string' && item.length > 0) rawUrls.push(item) + } + } else if (typeof value.image_url === 'string' && value.image_url.length > 0) { + rawUrls.push(value.image_url) + } + + // 兼容旧版 images 数组格式 + if (rawUrls.length === 0 && Array.isArray(value.images)) { + for (const image of value.images) { + if (isRecord(image) && typeof image.url === 'string' && image.url.length > 0) { + rawUrls.push(image.url) + } + } + } + + if (rawUrls.length === 0) return null + + const images: GeneratedImage[] = rawUrls.map((url) => ({ url })) + return { type: 'character_template', images } +} + export interface FirstFrameGenerationResult { type: 'first_frame' image: GeneratedImage } +export interface GeneratedAnimationFrame extends GeneratedImage { + durationMs: number | null +} + /** 帧顺序由数组位置表达。 */ export interface CompleteAnimationGenerationResult { type: 'complete_animation' - frames: readonly GeneratedImage[] + actionType: ActionType + frames: readonly GeneratedAnimationFrame[] +} + +/** 校验已经过适配层归一化的完整动画结果,供本地持久化恢复使用。 */ +export function parseCompleteAnimationGenerationResult( + value: unknown, +): CompleteAnimationGenerationResult | null { + if (!isRecord(value) || value.type !== 'complete_animation') return null + if (!['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType))) return null + if (!Array.isArray(value.frames) || value.frames.length === 0) return null + const frames: GeneratedAnimationFrame[] = [] + for (const frame of value.frames) { + if ( + !isRecord(frame) || + typeof frame.url !== 'string' || + frame.url.length === 0 || + (frame.durationMs !== null && typeof frame.durationMs !== 'number') + ) { + return null + } + frames.push({ url: frame.url, durationMs: frame.durationMs as number | null }) + } + return { type: 'complete_animation', actionType: value.actionType as ActionType, frames } } export type GenerationResult = @@ -98,50 +162,49 @@ export type GenerationResultFor = : CompleteAnimationGenerationResult /** - * 一次生成任务的完整快照,创建、查询和断线恢复都用它。 + * 一次生成任务的完整快照。 * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 - * - * TType 在调用边界已知时保留精确类型;按 ID 恢复时用默认值,等运行时解析后再收窄。 - * 完成不代表工作流节点已通过,节点状态由 WorkflowStep 自己判定。 */ export interface Generation { + /** 创建接口返回的后端任务 ID。 */ id: string projectId: string /** 与创建时的输入判别字段保持同一字面量类型。 */ type: TType - status: TaskStatus + status: GenerationTaskStatus /** 完成前为 null;完成后形状由 type 决定。 */ result: GenerationResult | null /** status 为 failed 时有值。 */ error: string | null } -/** - * 一条状态变更事件。 - * 不含 projectId:后端事件 payload 只有 task_id、task_type、status, - * 以及完成时的 result 和失败时的 error_message。 - */ +/** 后端任务状态变化映射成同一份 Generation 快照。 */ export interface GenerationEvent extends Omit< Generation, 'id' | 'projectId' > { - /** 对应 Generation.id,字段名沿用后端事件里的 task_id。 */ + /** 字段名对应后端事件中的 task_id,但语义上仍是 Generation.id。 */ taskId: Generation['id'] } -/** Generation 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */ +/** Generation 对应的一组后端接口。 */ export interface GenerationApis { /** 创建一次生成任务。 */ create(input: T): Promise> + /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ + get(projectId: Generation['projectId'], id: Generation['id']): Promise /** - * 按所属项目和任务 ID 读取最新快照。 - * projectId 不能从 id 推导,后端查询接口要求两者同时传入。 + * 订阅任务状态。当前后端没有 SSE 时,实现可以封装轮询;调用方不感知传输方式。 + * 返回取消订阅函数。 */ - get(projectId: Generation['projectId'], id: Generation['id']): Promise - /** 订阅状态变化,返回取消订阅函数。 */ subscribe( projectId: Generation['projectId'], id: Generation['id'], onEvent: (event: GenerationEvent) => void, + onError?: (error: Error) => void, ): () => void } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 79a3d212..35e0a477 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,33 +1,47 @@ -/** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ +/** + * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 + * 外部只从这里使用实体契约与已经落地的实体能力。 + */ + +/* 用户 —— 认证态与账户资料。 */ +export { createUserApis } from './user/api' +export type { CreateUserApisOptions } from './user/api' +export type { AuthTokens, User, UserApis } from './user' /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ -export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' -export { projectApis } from './project' +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { createProjectApis } from './project/api' export type { CharacterPerspective, CreateProjectInput, DirectionalMovement, Project, ProjectApis, - ProjectPageQuery, } from './project' /* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ export type { Action, + ActionKind, ActionType, + BaseFrame, Character, CharacterApis, + CharacterTemplateCandidate, + ConfirmCharacterTemplateInput, CreateCharacterInput, Frame, + FrameRootMotion, Outfit, } from './character' -export { characterApis } from './character' - -/* 动作模板 —— 能跨角色复用的配方 */ -export type { ActionTemplate, ActionTemplateApis } from './action-template' +export { createCharacterApis } from './character/api' -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export { + COMPLETE_ANIMATION_FRAME_COUNT, + parseCharacterTemplateGenerationResult, +} from './generation' +export { createGenerationApis } from './generation/api' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -35,6 +49,7 @@ export type { CompleteAnimationGenerationResult, FirstFrameGenerationInput, FirstFrameGenerationResult, + GeneratedAnimationFrame, GeneratedImage, Generation, GenerationApis, @@ -42,16 +57,32 @@ export type { GenerationInput, GenerationResult, GenerationResultFor, + GenerationTaskStatus, GenerationType, - TaskStatus, } from './generation' /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ -export type { MediaReference } from './media' +export { createMediaApis } from './media/api' +export type { MediaApis, MediaCategory, MediaReference } from './media' + +/* Playtest 核验 —— 每个动作当前最新的核验结论,不形成历史版本 */ +export { createPlaytestInspectionApis } from './playtest-inspection/api' +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from './playtest-inspection' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, + ActionGenerationWorkflowStep, + CreateWorkflowRunStoreOptions, CreateWorkflowRunInput, ExportStatus, GenerationStatus, @@ -62,6 +93,7 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/media/README.md b/frontend/src/entities/media/README.md new file mode 100644 index 00000000..e28ac888 --- /dev/null +++ b/frontend/src/entities/media/README.md @@ -0,0 +1,37 @@ +# Media Upload + +这个目录提供 Quick Start 和 Generation 上传参考图片时共用的最小适配能力,不包含上传按钮或页面状态。 + +## 用户点击上传后发生什么 + +页面把用户选中的 `File` 交给 `createMediaApis().upload(...)`。适配器先确认浏览器报告的 MIME 类型是 `image/*`,再把原文件装进 `FormData`,把用途分类放进 `category` 查询参数,通过 `POST /media/upload?category=...` 发送给后端。这一位置来自当前 `main` 的 FastAPI 路由声明:`file` 是表单文件,`category` 是查询参数。 + +后端会再次校验图片类型,并把文件写入已配置的对象存储。只有后端返回业务码 `200`,且 `url`、对象 key、文件名、图片 MIME 类型和文件大小都符合契约时,前端才把 `url` 作为 `MediaReference` 交给 Quick Start 或 Generation。业务失败、HTTP 失败、非法 JSON 和缺字段响应都会抛出错误,不会伪造成功。 + +调用方可以传入 `AbortSignal`。用户取消、离开页面或用新文件替换旧文件时,可终止仍在途的上传;浏览器的 `AbortError` 会原样返回,便于页面单独处理“取消”状态。 + +## 文件范围 + +- 接受范围与当前后端一致:MIME 类型为 `image/*` 的文件。 +- 当前契约没有声明扩展名白名单或大小上限,前端不擅自增加限制。 +- 默认分类是 `general`;参考图应传 `reference-image`。另有 `outfit-preview` 和 `action-frame`,与后端枚举一致。 +- 本模块不负责裁剪、压缩、预览、重试、进度显示或持久化,也不直接调用生成接口。 + +## 依赖 + +- 浏览器原生 `File`、`FormData`、`fetch` 和 `AbortSignal`。 +- 必须配置 `VITE_API_BASE_URL`。缺失时上传会明确报错,不会退回访问者本机的 `127.0.0.1`。 +- 后端 `POST /media/upload` 以及后端配置的对象存储。没有可用后端或对象存储时,上传应真实失败。 + +## 验收命令 + +在 `frontend` 目录执行: + +```powershell +npm.cmd run format:check +npm.cmd run lint +npm.cmd run typecheck +npm.cmd test -- src/entities/media/api.test.ts +npm.cmd test +npm.cmd run build +``` diff --git a/frontend/src/entities/media/api.test.ts b/frontend/src/entities/media/api.test.ts new file mode 100644 index 00000000..f879e9a4 --- /dev/null +++ b/frontend/src/entities/media/api.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createMediaApis } from '@/entities' + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('MediaApis.upload', () => { + it('把图片和默认查询分类交给后端,并返回经过校验的媒体引用', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + url: 'https://cdn.example.com/media/reference.png', + object_key: 'media/general/reference.png', + filename: 'reference.png', + content_type: 'image/png', + size: 4, + }), + ) + vi.stubGlobal('fetch', fetchMock) + const file = imageFile() + + const result = await createMediaApis().upload(file) + + expect(result).toBe('https://cdn.example.com/media/reference.png') + expect(fetchMock).toHaveBeenCalledTimes(1) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://127.0.0.1:8000/media/upload?category=general') + expect(init.method).toBe('POST') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + + const body = init.body as FormData + expect(body.get('file')).toBe(file) + expect(body.has('category')).toBe(false) + }) + + it('传递调用方选择的图片用途和取消信号', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + url: 'https://cdn.example.com/media/reference.png', + object_key: 'media/reference-image/reference.png', + filename: 'reference.png', + content_type: 'image/png', + size: 4, + }), + ) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + + await createMediaApis().upload(imageFile(), 'reference-image', controller.signal) + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:8000/media/upload?category=reference-image', + ) + expect((init.body as FormData).has('category')).toBe(false) + expect(init.signal).toBe(controller.signal) + }) + + it('在请求发出前拒绝非图片文件', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const file = new File(['text'], 'notes.txt', { type: 'text/plain' }) + + await expect(createMediaApis().upload(file)).rejects.toThrow('仅支持图片文件') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('后端地址未配置时明确失败,不把文件发送到访问者本机', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubEnv('VITE_API_BASE_URL', '') + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'UploadConfigurationError', + message: '媒体上传不可用:请配置 VITE_API_BASE_URL', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('把 HTTP 200 中的后端业务失败作为真实错误抛出', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 400, message: '仅支持图片文件', data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'UploadRequestError', + status: 200, + code: 400, + message: '仅支持图片文件', + }) + }) + + it('保留非成功 HTTP 响应的状态和后端错误信息', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 503, message: '对象存储暂不可用', data: null }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'UploadRequestError', + status: 503, + code: 503, + message: '对象存储暂不可用', + }) + }) + + it('拒绝无法解析的 JSON 响应', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toThrow( + '上传响应格式错误,无法解析 JSON', + ) + }) + + it.each([ + ['url 为空', { url: '' }], + ['object_key 缺失', { object_key: undefined }], + ['content_type 不是图片', { content_type: 'text/plain' }], + ['size 不是非负整数', { size: -1 }], + ])('拒绝不符合后端契约的成功数据:%s', async (_caseName, override) => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + url: 'https://cdn.example.com/media/reference.png', + object_key: 'media/general/reference.png', + filename: 'reference.png', + content_type: 'image/png', + size: 4, + ...override, + }), + ), + ) + + await expect(createMediaApis().upload(imageFile())).rejects.toMatchObject({ + name: 'MediaContractError', + }) + }) + + it('不包装浏览器抛出的取消错误', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const abortError = new DOMException('This operation was aborted', 'AbortError') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)) + const controller = new AbortController() + controller.abort() + + await expect( + createMediaApis().upload(imageFile(), 'reference-image', controller.signal), + ).rejects.toBe(abortError) + }) +}) + +function imageFile(): File { + return new File(['wind'], 'reference.png', { type: 'image/png' }) +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/media/api.ts b/frontend/src/entities/media/api.ts new file mode 100644 index 00000000..14cf3d6d --- /dev/null +++ b/frontend/src/entities/media/api.ts @@ -0,0 +1,81 @@ +import { upload as uploadRequest } from '@/shared/api/upload' + +import type { MediaApis, MediaCategory, MediaReference } from '.' + +/** 后端声称上传成功、但返回数据不符合 /media/upload 契约。 */ +export class MediaContractError extends Error { + constructor(message: string) { + super(`媒体上传响应格式错误:${message}`) + this.name = 'MediaContractError' + } +} + +/** /media/upload 成功时 data 字段的后端原始形状。 */ +interface BackendMediaUpload { + url: string + object_key: string + filename: string + content_type: string + size: number +} + +/** + * 创建真实媒体上传适配器。这里不缓存文件、不生成本地假 URL,也不吞掉错误; + * 只有服务端确认成功且完整响应通过运行时校验后,才交付 MediaReference。 + */ +export function createMediaApis(): MediaApis { + return { + async upload( + file: File, + category: MediaCategory = 'general', + signal?: AbortSignal, + ): Promise { + // 与后端的 image/* 规则一致,尽早反馈可避免上传无效文件;后端仍是最终校验者。 + if (!file.type.startsWith('image/')) { + throw new TypeError('仅支持图片文件') + } + + const formData = new FormData() + formData.append('file', file) + + // main 的 FastAPI 路由只把 file 声明为 File;category 未声明 Form,因此属于查询参数。 + const query = new URLSearchParams({ category }) + const result = await uploadRequest(`/media/upload?${query}`, formData, signal) + return parseMediaReference(result) + }, + } +} + +function parseMediaReference(value: unknown): MediaReference { + assertBackendMediaUpload(value) + + // MediaReference 是不透明引用;当前后端明确约定用已校验的 url 回填业务数据。 + return value.url as MediaReference +} + +function assertBackendMediaUpload(value: unknown): asserts value is BackendMediaUpload { + if (!isRecord(value)) { + throw new MediaContractError('data 必须是对象') + } + + assertNonEmptyString(value.url, 'url') + assertNonEmptyString(value.object_key, 'object_key') + assertNonEmptyString(value.filename, 'filename') + + if (typeof value.content_type !== 'string' || !value.content_type.startsWith('image/')) { + throw new MediaContractError('content_type 必须是 image/*') + } + if (typeof value.size !== 'number' || !Number.isInteger(value.size) || value.size < 0) { + throw new MediaContractError('size 必须是非负整数') + } +} + +function assertNonEmptyString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.trim() === '') { + throw new MediaContractError(`${field} 必须是非空字符串`) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts index 347e626b..ce7747f0 100644 --- a/frontend/src/entities/media/index.ts +++ b/frontend/src/entities/media/index.ts @@ -7,3 +7,17 @@ declare const mediaReferenceBrand: unique symbol export type MediaReference = string & { readonly [mediaReferenceBrand]: 'MediaReference' } + +/** 上传媒体时的业务用途;值与后端 MediaCategory 枚举逐项对应。 */ +export type MediaCategory = 'reference-image' | 'outfit-preview' | 'action-frame' | 'general' + +/** + * 媒体实体对页面和生成流程暴露的最小能力。 + * signal 用于页面离开、用户取消或新上传替换旧上传时终止仍在途的请求。 + */ +export interface MediaApis { + upload(file: File, category?: MediaCategory, signal?: AbortSignal): Promise +} + +// 上层只能通过 @/entities 公共入口取得真实适配器,避免页面深度导入内部文件。 +export { createMediaApis, MediaContractError } from './api' diff --git a/frontend/src/entities/playtest-inspection/api.test.ts b/frontend/src/entities/playtest-inspection/api.test.ts new file mode 100644 index 00000000..adb19d7f --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createPlaytestInspectionApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Playtest inspection API adapter', () => { + it('treats the backend business 404 as an empty current inspection', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 404, message: '尚未核验', data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect( + createPlaytestInspectionApis().get({ + characterId: '25', + outfitId: 'default', + actionId: 'idle', + }), + ).resolves.toBeNull() + }) + + it('sends stable target IDs and maps the saved inspection', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: '核验已保存', + data: { + id: 9, + character_id: 25, + outfit_id: 'default', + action_id: 'idle', + status: 'issues_found', + create_at: '2026-08-04T00:00:00Z', + update_at: '2026-08-04T00:01:00Z', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const saved = await createPlaytestInspectionApis().save({ + characterId: '25', + outfitId: 'default', + actionId: 'idle', + status: 'issues_found', + }) + + expect(saved).toMatchObject({ id: '9', characterId: '25', status: 'issues_found' }) + const request = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(JSON.parse(String(request.body))).toEqual({ + character_id: 25, + outfit_id: 'default', + action_id: 'idle', + status: 'issues_found', + }) + }) +}) diff --git a/frontend/src/entities/playtest-inspection/api.ts b/frontend/src/entities/playtest-inspection/api.ts new file mode 100644 index 00000000..6c62412e --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.ts @@ -0,0 +1,65 @@ +import { ApiError, get, post } from '@/shared/api' + +import type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from '.' + +interface BackendPlaytestInspection { + id: number + character_id: number + outfit_id: string + action_id: string + status: PlaytestInspection['status'] + create_at: string + update_at: string +} + +function toInspection(raw: BackendPlaytestInspection): PlaytestInspection { + return { + id: String(raw.id), + characterId: String(raw.character_id), + outfitId: raw.outfit_id, + actionId: raw.action_id, + status: raw.status, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +function queryFor(target: PlaytestInspectionTarget): string { + const query = new URLSearchParams({ + character_id: target.characterId, + outfit_id: target.outfitId, + action_id: target.actionId, + }) + return query.toString() +} + +export function createPlaytestInspectionApis(): PlaytestInspectionApis { + return { + async get(target) { + try { + const raw = await get( + `/playtest-inspections?${queryFor(target)}`, + ) + return toInspection(raw) + } catch (cause) { + if (cause instanceof ApiError && cause.code === 404) return null + throw cause + } + }, + + async save(input: SavePlaytestInspectionInput) { + const raw = await post('/playtest-inspections', { + character_id: Number(input.characterId), + outfit_id: input.outfitId, + action_id: input.actionId, + status: input.status, + }) + return toInspection(raw) + }, + } +} diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts new file mode 100644 index 00000000..2a160165 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/index.ts @@ -0,0 +1,24 @@ +/** Playtest 对某个动作保存的当前核验结论,不属于资产或创作历史。 */ +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +export interface PlaytestInspectionTarget { + characterId: string + outfitId: string + actionId: string +} + +export interface PlaytestInspection extends PlaytestInspectionTarget { + id: string + status: PlaytestInspectionStatus + createdAt: string + updatedAt: string +} + +export interface SavePlaytestInspectionInput extends PlaytestInspectionTarget { + status: PlaytestInspectionStatus +} + +export interface PlaytestInspectionApis { + get(target: PlaytestInspectionTarget): Promise + save(input: SavePlaytestInspectionInput): Promise +} diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts new file mode 100644 index 00000000..c83e4977 --- /dev/null +++ b/frontend/src/entities/project/api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createProjectApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('project API adapter', () => { + it('maps backend projects without losing server pagination metadata', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [ + { + id: 3, + user_id: 7, + project_name: '像素冒险', + character_perspective: 1, + directional_movement: 2, + sprite_width: 64, + sprite_height: 96, + workflow_id: null, + game_style: '明亮像素风', + sprite_sample_url: null, + create_at: '2026-08-01T00:00:00Z', + update_at: '2026-08-02T00:00:00Z', + }, + ], + total: 21, + page: 2, + page_size: 10, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await createProjectApis().list({ page: 2, pageSize: 10 }) + + expect(result).toMatchObject({ total: 21, page: 2, pageSize: 10 }) + expect(result.items[0]).toMatchObject({ + id: '3', + ownerId: '7', + name: '像素冒险', + perspective: 'side', + directionalMovement: 'four-way', + spriteSize: { width: 64, height: 96 }, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8000/projects?page=2&page_size=10', + expect.any(Object), + ) + }) +}) diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts new file mode 100644 index 00000000..25eaba52 --- /dev/null +++ b/frontend/src/entities/project/api.ts @@ -0,0 +1,105 @@ +import type { CreateProjectInput, Project, ProjectApis, ProjectPageQuery } from '.' +import type { Paged } from '@/shared/pagination' + +import { del, get, getPage, post } from '@/shared/api' + +/* ─── 后端 DTO ─── */ + +interface BackendProject { + id: number + user_id: number + project_name: string + character_perspective: number + directional_movement: number + sprite_width: number + sprite_height: number + workflow_id: number | null + game_style: string | null + sprite_sample_url: string | null + create_at: string + update_at: string +} + +/* ─── 映射 ─── */ + +const PERSPECTIVE_MAP: Record = { + 1: 'side', + 2: 'top-down', + 3: 'isometric', +} + +const MOVEMENT_MAP: Record = { + 1: 'single', + 2: 'four-way', + 3: 'eight-way', +} + +function toProject(raw: BackendProject): Project { + return { + id: String(raw.id), + ownerId: String(raw.user_id), + workflowId: raw.workflow_id === null ? null : String(raw.workflow_id), + name: raw.project_name, + perspective: PERSPECTIVE_MAP[raw.character_perspective] ?? 'side', + directionalMovement: MOVEMENT_MAP[raw.directional_movement] ?? 'single', + spriteSize: { width: raw.sprite_width, height: raw.sprite_height }, + gameStyle: raw.game_style, + sampleImageUrl: raw.sprite_sample_url, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +function toPositiveId(value: string | undefined, fallback: number, field: string): number { + const parsed = value === undefined ? fallback : Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError(`${field} 必须是正整数 ID`) + } + return parsed +} + +function toCreatePayload(input: CreateProjectInput) { + return { + user_id: toPositiveId(input.ownerId, 1, 'ownerId'), + workflow_id: + input.workflowId === undefined || input.workflowId === null + ? (input.workflowId ?? null) + : toPositiveId(input.workflowId, 1, 'workflowId'), + project_name: input.name, + character_perspective: { side: 1, 'top-down': 2, isometric: 3 }[input.perspective], + directional_movement: { single: 1, 'four-way': 2, 'eight-way': 3 }[input.directionalMovement], + sprite_width: input.spriteSize.width, + sprite_height: input.spriteSize.height, + game_style: input.gameStyle ?? null, + sprite_sample_url: input.sampleImageUrl ?? null, + } +} + +/* ─── 适配器 ─── */ + +export function createProjectApis(): ProjectApis { + return { + async list(query?: ProjectPageQuery): Promise> { + const params = new URLSearchParams() + if (query?.page) params.set('page', String(query.page)) + if (query?.pageSize) params.set('page_size', String(query.pageSize)) + if (query?.ownerId) params.set('user_id', String(toPositiveId(query.ownerId, 1, 'ownerId'))) + const qs = params.toString() + const result = await getPage(`/projects${qs ? `?${qs}` : ''}`) + return { ...result, items: result.items.map(toProject) } + }, + + async get(id: string): Promise { + const raw = await get(`/projects/${encodeURIComponent(id)}`) + return toProject(raw) + }, + + async create(input: CreateProjectInput): Promise { + return toProject(await post('/projects', toCreatePayload(input))) + }, + + async remove(id: string): Promise { + await del(`/projects/${encodeURIComponent(id)}`) + }, + } +} diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts index d630e14b..9b936fc9 100644 --- a/frontend/src/entities/project/index.test.ts +++ b/frontend/src/entities/project/index.test.ts @@ -24,7 +24,7 @@ afterEach(() => { async function loadProjectApis(fetchFn: typeof fetch) { vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', fetchFn) - return (await import('./index')).projectApis + return (await import('./api')).createProjectApis() } function jsonResponse(data: unknown) { diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 79152358..c4e5f797 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -1,24 +1,36 @@ -import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' import type { Paged, PageQuery } from '@/shared/pagination' -/** Project 前端领域形状;字段名由本模块显式映射后端 ProjectOut。 */ +/** Project 前端领域形状;字段只表达当前页面需要,不对应任何已确认后端 DTO。 */ export interface Project { id: string + /** Project 所属用户 ID;认证来源尚未冻结。 */ ownerId: string - workflowId: string | null + /** 后端关联的工作流 ID;旧数据或尚未关联时为 null。 */ + workflowId?: string | null name: string + /** 游戏视角,见 CHARACTER_PERSPECTIVE。 */ perspective: CharacterPerspective + /** 移动方向,见 DIRECTIONAL_MOVEMENT。 */ directionalMovement: DirectionalMovement + /** 当前页面使用建议档位,后端范围尚未确认。 */ spriteSize: { width: number; height: number } + /** 项目级画风描述,作为本项目所有角色和动作生成的视觉约束。 */ gameStyle: string | null + /** + * 项目级画风参考图,本项目所有角色和动作都遵循它的视觉风格。 + * 它不决定某个角色具体长什么样;角色自身参考图由 CreateCharacterInput.referenceImageUrl 表达。 + */ sampleImageUrl: string | null + /** ISO 8601 字符串。 */ createdAt: string + /** ISO 8601 字符串。 */ updatedAt: string } -/** 后端创建 Project 需要的完整字段;创建流程本身不属于资产库页面。 */ +/** 新建项目的入参。 */ export interface CreateProjectInput { - ownerId: string + /** 认证模块接入前可省略,由组合层使用当前开发用户。 */ + ownerId?: string workflowId?: string | null name: string perspective: CharacterPerspective @@ -29,162 +41,40 @@ export interface CreateProjectInput { } export interface ProjectPageQuery extends PageQuery { - /** 对应后端 user_id;后端按登录用户强制隔离前保持可选。 */ ownerId?: string } -/** 后端 character_perspective: 1 横版 / 2 俯视 / 3 2.5D。 */ +/** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ export type CharacterPerspective = 'side' | 'top-down' | 'isometric' -/** 后端 directional_movement: 1 单向 / 2 四向 / 3 八向。 */ +/** 前端使用的移动方向枚举;后端映射尚未冻结。 */ export type DirectionalMovement = 'single' | 'four-way' | 'eight-way' +/** 游戏视角的页面文案。 */ export const CHARACTER_PERSPECTIVE: Record = { side: '横版视角', 'top-down': '俯视', isometric: '2.5D', } +/** + * 移动方向,决定一个动作要生成几套朝向的帧。 + * 多朝向在 Action 上如何存放尚未定义,当前 Action.frames 只表达单朝向; + * 选了四向/八向的项目,生成侧还接不上,见 Action.frames 的说明。 + */ export const DIRECTIONAL_MOVEMENT: Record = { single: '单向', 'four-way': '四向', 'eight-way': '八向', } -/** Project 对应的一组后端接口。PR #75 未提供更新端点,因此这里不声明 update。 */ +/** UI 使用的建议尺寸档位;不代表后端约束。 */ +export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const + +/** Project 对应的一组后端接口。 */ export interface ProjectApis { list(query?: ProjectPageQuery): Promise> get(id: Project['id']): Promise create(input: CreateProjectInput): Promise remove(id: Project['id']): Promise } - -interface ProjectDto { - id: number - user_id: number - workflow_id: number | null - project_name: string - character_perspective: number - directional_movement: number - sprite_width: number - sprite_height: number - game_style: string | null - sprite_sample_url: string | null - create_at: string - update_at: string -} - -const perspectiveFromDto: Record = { - 1: 'side', - 2: 'top-down', - 3: 'isometric', -} - -const perspectiveToDto: Record = { - side: 1, - 'top-down': 2, - isometric: 3, -} - -const movementFromDto: Record = { - 1: 'single', - 2: 'four-way', - 3: 'eight-way', -} - -const movementToDto: Record = { - single: 1, - 'four-way': 2, - 'eight-way': 3, -} - -function mapEnumValue(value: number, values: Record, field: string): T { - const mapped = values[value] - if (mapped !== undefined) return mapped - throw new ApiError(`后端 Project.${field} 无效`, { - kind: 'invalid-response', - data: value, - }) -} - -function toBackendId(value: string, field: string): number { - const parsed = Number(value) - if (Number.isSafeInteger(parsed) && parsed > 0) return parsed - throw new TypeError(`${field} 必须是正整数 ID`) -} - -function mapProject(dto: ProjectDto): Project { - return { - id: String(dto.id), - ownerId: String(dto.user_id), - workflowId: dto.workflow_id === null ? null : String(dto.workflow_id), - name: dto.project_name, - perspective: mapEnumValue( - dto.character_perspective, - perspectiveFromDto, - 'character_perspective', - ), - directionalMovement: mapEnumValue( - dto.directional_movement, - movementFromDto, - 'directional_movement', - ), - spriteSize: { width: dto.sprite_width, height: dto.sprite_height }, - gameStyle: dto.game_style, - sampleImageUrl: dto.sprite_sample_url, - createdAt: dto.create_at, - updatedAt: dto.update_at, - } -} - -function getApiClient() { - return createApiClient({ getAccessToken: getApiAccessToken }) -} - -export const projectApis: ProjectApis = { - async list(query = {}) { - const result = await getApiClient().requestList('/projects', { - query: { - page: query.page, - page_size: query.pageSize, - user_id: query.ownerId ? toBackendId(query.ownerId, 'ownerId') : undefined, - }, - }) - return { ...result, items: result.items.map(mapProject) } - }, - - async get(id) { - return mapProject( - await getApiClient().request(`/projects/${encodeURIComponent(id)}`), - ) - }, - - async create(input) { - const dto = await getApiClient().request('/projects', { - method: 'POST', - json: { - user_id: toBackendId(input.ownerId, 'ownerId'), - workflow_id: - input.workflowId === undefined - ? undefined - : input.workflowId === null - ? null - : toBackendId(input.workflowId, 'workflowId'), - project_name: input.name, - character_perspective: perspectiveToDto[input.perspective], - directional_movement: movementToDto[input.directionalMovement], - sprite_width: input.spriteSize.width, - sprite_height: input.spriteSize.height, - game_style: input.gameStyle, - sprite_sample_url: input.sampleImageUrl, - }, - }) - return mapProject(dto) - }, - - async remove(id) { - await getApiClient().request(`/projects/${encodeURIComponent(id)}`, { - method: 'DELETE', - }) - }, -} diff --git a/frontend/src/entities/user/api.test.ts b/frontend/src/entities/user/api.test.ts new file mode 100644 index 00000000..d163c260 --- /dev/null +++ b/frontend/src/entities/user/api.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' + +import { registerApiAccessTokenProvider } from '@/shared/api' +import { createUserApis } from './api' + +describe('user API adapter', () => { + it('uses the authenticated backend contract and maps user responses to the entity shape', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { + const path = new URL(String(input)).pathname + if (path === '/auth/me') return jsonResponse(backendUser) + if ( + ['/auth/register', '/auth/login', '/auth/login-by-code', '/auth/refresh'].includes(path) + ) { + return jsonResponse({ access_token: 'access', refresh_token: 'refresh', user: backendUser }) + } + return jsonResponse(null) + }) + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await apis.sendCode({ email: 'a@b.com', purpose: 'login' }) + await apis.register({ + email: 'a@b.com', + password: 'password1', + code: '123456', + nickname: 'Ada', + }) + await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' }) + await apis.loginByCode({ email: 'a@b.com', code: '123456' }) + await apis.refresh('refresh') + await apis.logout('refresh') + expect(await apis.me()).toEqual({ + id: 7, + email: 'a@b.com', + nickname: null, + emailVerifiedAt: null, + status: 'normal', + }) + await apis.changePassword({ oldPassword: 'password1', newPassword: 'password2' }) + + expect(await apis.login({ email: 'a@b.com', password: 'password1', code: '123456' })).toEqual({ + accessToken: 'access', + refreshToken: 'refresh', + user: { id: 7, email: 'a@b.com', nickname: null, emailVerifiedAt: null, status: 'normal' }, + }) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.example.test/auth/send-code', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', purpose: 'login' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.example.test/auth/register', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'a@b.com', + password: 'password1', + code: '123456', + nickname: 'Ada', + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://api.example.test/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', password: 'password1', code: '123456' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + 'https://api.example.test/auth/login-by-code', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', code: '123456' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + 'https://api.example.test/auth/refresh', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ refresh_token: 'refresh' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 6, + 'https://api.example.test/auth/logout', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ refresh_token: 'refresh' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 7, + 'https://api.example.test/auth/me', + expect.any(Object), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 8, + 'https://api.example.test/auth/change-password', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ old_password: 'password1', new_password: 'password2' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 9, + 'https://api.example.test/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'a@b.com', password: 'password1', code: '123456' }), + }), + ) + }) + + it('maps a banned backend user status to the entity status', async () => { + const fetchMock = vi.fn( + async (): Promise => jsonResponse({ ...backendUser, status: 1 }), + ) + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await expect(apis.me()).resolves.toMatchObject({ id: 7, status: 'banned' }) + }) + + it('uses the registered session access token for authenticated requests by default', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer session-access') + return jsonResponse(backendUser) + }) + const unregister = registerApiAccessTokenProvider(() => 'session-access') + + try { + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await expect(apis.me()).resolves.toMatchObject({ id: 7 }) + expect(fetchMock).toHaveBeenCalledTimes(1) + } finally { + unregister() + } + }) + + it('preserves an explicit access-token provider override', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer explicit-access') + return jsonResponse(backendUser) + }) + const unregister = registerApiAccessTokenProvider(() => 'session-access') + + try { + const apis = createUserApis({ + baseUrl: 'https://api.example.test', + fetchFn: fetchMock, + getAccessToken: () => 'explicit-access', + }) + + await expect(apis.me()).resolves.toMatchObject({ id: 7 }) + } finally { + unregister() + } + }) + + it('rejects malformed user and token DTOs instead of treating them as valid authentication data', async () => { + const invalidUser = { ...backendUser, status: 2 } + const invalidTokens = { refresh_token: 'refresh', user: backendUser } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(invalidUser)) + .mockResolvedValueOnce(jsonResponse(invalidTokens)) + const apis = createUserApis({ baseUrl: 'https://api.example.test', fetchFn: fetchMock }) + + await expect(apis.me()).rejects.toMatchObject({ kind: 'invalid-response', data: invalidUser }) + await expect( + apis.login({ email: 'a@b.com', password: 'password1', code: '123456' }), + ).rejects.toMatchObject({ + kind: 'invalid-response', + data: invalidTokens, + }) + }) +}) + +const backendUser = { + id: 7, + email: 'a@b.com', + nickname: null, + email_verified_at: null, + status: 0, +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts new file mode 100644 index 00000000..53b86038 --- /dev/null +++ b/frontend/src/entities/user/api.ts @@ -0,0 +1,138 @@ +import type { AuthTokens, User, UserApis } from '.' + +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { ApiClient, ApiClientOptions } from '@/shared/api' + +interface BackendUser { + id: number + email: string + nickname: string | null + email_verified_at: string | null + status: number +} + +interface BackendAuthTokens { + access_token: string + refresh_token: string + user: BackendUser +} + +export interface CreateUserApisOptions extends ApiClientOptions { + client?: ApiClient +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidResponse(data: unknown): never { + throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data }) +} + +function toUser(raw: unknown): User { + if ( + !isRecord(raw) || + typeof raw.id !== 'number' || + typeof raw.email !== 'string' || + (typeof raw.nickname !== 'string' && raw.nickname !== null) || + (typeof raw.email_verified_at !== 'string' && raw.email_verified_at !== null) || + (raw.status !== 0 && raw.status !== 1) + ) { + invalidResponse(raw) + } + + return { + id: raw.id, + email: raw.email, + nickname: raw.nickname, + emailVerifiedAt: raw.email_verified_at, + status: raw.status === 1 ? 'banned' : 'normal', + } +} + +function toAuthTokens(raw: unknown): AuthTokens { + if ( + !isRecord(raw) || + typeof raw.access_token !== 'string' || + typeof raw.refresh_token !== 'string' || + !isRecord(raw.user) + ) { + invalidResponse(raw) + } + + return { + accessToken: raw.access_token, + refreshToken: raw.refresh_token, + user: toUser(raw.user), + } +} + +export function createUserApis(options: CreateUserApisOptions = {}): UserApis { + const { client, ...clientOptions } = options + const apiClient = + client ?? + createApiClient({ + ...clientOptions, + getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken, + }) + + return { + async sendCode(input): Promise { + await apiClient.request('/auth/send-code', { method: 'POST', json: input }) + }, + + async register(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/register', { + method: 'POST', + json: input, + }), + ) + }, + + async login(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/login', { + method: 'POST', + json: input, + }), + ) + }, + + async loginByCode(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/login-by-code', { + method: 'POST', + json: input, + }), + ) + }, + + async refresh(refreshToken): Promise { + return toAuthTokens( + await apiClient.request('/auth/refresh', { + method: 'POST', + json: { refresh_token: refreshToken }, + }), + ) + }, + + async logout(refreshToken): Promise { + await apiClient.request('/auth/logout', { + method: 'POST', + json: { refresh_token: refreshToken }, + }) + }, + + async me(): Promise { + return toUser(await apiClient.request('/auth/me')) + }, + + async changePassword(input): Promise { + await apiClient.request('/auth/change-password', { + method: 'POST', + json: { old_password: input.oldPassword, new_password: input.newPassword }, + }) + }, + } +} diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 00000000..4ff9e737 --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,35 @@ +/** 已认证用户的前端领域表示。 */ +export interface User { + id: number + email: string + nickname: string | null + emailVerifiedAt: string | null + status: 'normal' | 'banned' +} + +/** 一次认证成功后由后端签发的访问与刷新令牌。 */ +export interface AuthTokens { + accessToken: string + refreshToken: string + user: User +} + +/** 用户认证与账户设置的后端接口。 */ +export interface UserApis { + sendCode(input: { + email: string + purpose: 'login' | 'register' | 'reset_password' + }): Promise + register(input: { + email: string + password: string + code: string + nickname?: string + }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 00000000..2507ad30 --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,16 @@ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 当前产品工作流的固定五步,也是存储校验与进度 UI 的唯一顺序来源。 */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-generation', + 'review', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b5..235e57ab 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,26 +1,34 @@ -import type { Generation } from '../generation' +import type { + Generation, + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, +} from '../generation' +import type { MediaReference } from '../media' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { WORKFLOW_STEP_ORDER } from './constants' /** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] /** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] /** * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.steps 的数组位置表达。 */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const - /** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] @@ -28,63 +36,103 @@ export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] * 步骤的可用性和执行结果;不直接复用后端任务状态。 * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] /** * 单个版本的生命周期。 * abandoned 表示停止沿用但仍保留为历史。 */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] /** * 整次流程的汇总状态。 * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + * 后端 Generation 是否真正停止是独立问题;前端中断只停止自动推进与订阅。 */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] -/** - * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 - * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。 - */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' +/** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] /** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' +export type ExportStatus = (typeof EXPORT_STATUSES)[number] -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { +interface WorkflowStepBase { /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ id: string - type: WorkflowStepType status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 + * 任务本身不认识步骤,反向关联不存在。 */ taskId: Generation['id'] | null + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交;若页面在这个窗口刷新, + * Controller 会把本地 Run 标为失败。它不是后端字段,也不冒充幂等键。 + */ + submissionId: string | null + /** 步骤失败后供页面解释原因;未失败时必须为 null。 */ + error: string | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ referenceStepIds: string[] } +/** 角色资料步骤保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupStepInput { + description: string + referenceMedia: readonly MediaReference[] +} + +export interface CharacterSetupWorkflowStep extends WorkflowStepBase { + type: 'character-setup' + input: CharacterSetupStepInput | null + output: null +} + +export interface CharacterTemplateWorkflowStep extends WorkflowStepBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterTemplateGenerationInput | null + output: CharacterTemplateGenerationResult | null +} + +export interface ActionGenerationWorkflowStep extends WorkflowStepBase { + type: 'action-generation' + input: CompleteAnimationGenerationInput | null + output: CompleteAnimationGenerationResult | null +} + +type RemainingWorkflowStepType = Exclude< + WorkflowStepType, + 'character-setup' | 'character-template' | 'action-generation' +> + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选确认与审核的具体输入输出在对应纵切中继续收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;后续三步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | ActionGenerationWorkflowStep + | RemainingWorkflowStep + /** * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + * 从历史步骤重开时,旧 Revision 保留为只读记录,新 Revision 引用它的重开步骤。 + * 新执行线中的下游步骤会清空并重新锁定,不能作为新生成的参考依据。 */ export interface WorkflowRevision { id: string @@ -94,8 +142,8 @@ export interface WorkflowRevision { restartStepId: string | null status: WorkflowRevisionStatus /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + * 当前版本固定保存全部五步;数组位置是步骤顺序的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 */ steps: WorkflowStep[] generationStatus: GenerationStatus @@ -155,3 +203,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & baseFrameUrls: readonly string[] } ) + +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts new file mode 100644 index 00000000..91e4c784 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_STEP_ORDER } from './constants' +import type { WorkflowRun, WorkflowStep } from './index' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' + +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem(): string | null { + return this.value + } + + setItem(_key: string, value: string): void { + if (this.failOnSet) throw new Error('storage unavailable') + this.value = value + } +} + +function createSteps(): WorkflowStep[] { + return WORKFLOW_STEP_ORDER.map((type, index) => { + const common = { + id: `revision-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + if (type === 'character-setup') { + return { + ...common, + type, + input: { description: 'slime', referenceMedia: [] }, + output: null, + } + } + if (type === 'character-template') { + return { ...common, type, input: null, output: null } + } + return { ...common, type, input: null, output: null } as WorkflowStep + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-30T12:00:00.000Z', + }, + ], + prompt: 'Create a slime', + } +} + +function createLegacyRun(): unknown { + const run = createRun() + const revision = run.revisions[0] + if (!revision) throw new Error('Expected a revision') + + const [characterSetup, characterTemplate, templateCandidate, actionGeneration, review] = + revision.steps + if (!characterSetup || !characterTemplate || !templateCandidate || !actionGeneration || !review) { + throw new Error('Expected the five current workflow steps') + } + + return { + ...run, + revisions: [ + { + ...revision, + steps: [ + characterSetup, + characterTemplate, + templateCandidate, + { ...actionGeneration, id: 'revision-1:action-setup', type: 'action-setup' }, + { ...actionGeneration, id: 'revision-1:first-frame', type: 'first-frame' }, + { ...actionGeneration, id: 'revision-1:complete-animation', type: 'complete-animation' }, + review, + { ...review, id: 'revision-1:export', type: 'export' }, + ], + }, + ], + } +} + +function createRestartedRun(): WorkflowRun { + const source = createRun() + const sourceRevision = source.revisions[0]! + const sourceSteps = sourceRevision.steps.map((step) => + step.type === 'character-setup' || step.type === 'character-template' + ? { ...step, status: 'passed' as const } + : step.type === 'template-candidate' + ? { ...step, status: 'active' as const } + : step, + ) + const restartedSteps = sourceSteps.map((step, index) => { + const common = { + ...step, + id: `revision-2:${step.type}`, + taskId: null, + submissionId: null, + error: null, + } + if (index === 0) return { ...common, status: 'passed' as const, referenceStepIds: [step.id] } + if (index === 1) { + return { + ...common, + status: 'active' as const, + output: null, + referenceStepIds: [step.id], + } + } + return { ...common, status: 'locked' as const, input: null, output: null, referenceStepIds: [] } + }) as WorkflowStep[] + + return { + ...source, + currentRevisionId: 'revision-2', + revisions: [ + { ...sourceRevision, status: 'abandoned', steps: sourceSteps }, + { + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + status: 'active', + steps: restartedSteps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-31T03:00:00.000Z', + }, + ], + } +} + +describe('createWorkflowRunStore', () => { + it('lists cloned snapshots and notifies whole-store subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const listener = vi.fn() + const unsubscribe = store.subscribeAll(listener) + + store.save(createRun('run-1')) + store.save(createRun('run-2')) + + expect(store.list().map((run) => run.id)).toEqual(['run-1', 'run-2']) + expect(listener).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: 'run-1' }), + expect.objectContaining({ id: 'run-2' }), + ]) + unsubscribe() + store.save(createRun('run-3')) + expect(listener).toHaveBeenCalledTimes(2) + }) + it('stores a versioned snapshot and returns defensive clones', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const source = createRun() + + store.save(source) + source.prompt = 'mutated outside' + + const firstRead = store.get(source.id) + expect(firstRead?.prompt).toBe('Create a slime') + + firstRead!.revisions[0].steps[0].status = 'failed' + expect(store.get(source.id)?.revisions[0].steps[0].status).toBe('active') + + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates valid runs from localStorage', () => { + const run = createRun() + const storage = new TestStorage( + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [run], + }), + ) + + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + }) + + it('migrates version-three single-frame action output without dropping history', () => { + const run = createRun() + const revision = run.revisions[0]! + revision.steps = revision.steps.map((step) => { + if (step.type === 'character-setup') return { ...step, status: 'passed' } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed', + output: { type: 'character_template', images: [{ url: 'template.png' }] }, + } + } + if (step.type === 'template-candidate') return { ...step, status: 'passed' } + if (step.type === 'action-generation') { + return { + ...step, + status: 'passed', + input: { + type: 'complete_animation', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionType: 'idle', + firstFrameUrl: 'template.png', + prompt: null, + referenceMedia: ['template.png'], + }, + output: { type: 'first_frame', image: { url: 'frame.png' } }, + } as unknown as WorkflowStep + } + return { ...step, status: 'active' } + }) + const storage = new TestStorage(JSON.stringify({ version: 3, runs: [run] })) + + const restored = createWorkflowRunStore({ storage }).get(run.id) + const action = restored?.revisions[0]?.steps.find((step) => step.type === 'action-generation') + + expect(action?.output).toEqual({ + type: 'complete_animation', + actionType: 'idle', + frames: [{ url: 'frame.png', durationMs: null }], + }) + }) + + it('migrates a version-one run to the fixed five-step model', () => { + const store = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ + version: 1, + runs: [createLegacyRun()], + }), + ), + }) + + expect(store.get('run-1')?.revisions[0]?.steps.map((step) => step.type)).toEqual([ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-generation', + 'review', + ]) + expect(store.get('run-1')?.revisions[0]?.exportStatus).toBe('not_exported') + }) + + it('migrates version-two runs and restores their restart history', () => { + const legacyRun = createRun() + const versionTwoStore = createWorkflowRunStore({ + storage: new TestStorage(JSON.stringify({ version: 2, runs: [legacyRun] })), + }) + const historyStore = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [createRestartedRun()] }), + ), + }) + + expect(versionTwoStore.get('run-1')).toEqual(legacyRun) + expect(historyStore.get('run-1')).toMatchObject({ + currentRevisionId: 'revision-2', + revisions: [ + { id: 'revision-1', status: 'abandoned' }, + { + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + }, + ], + }) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + ['invalid payload', JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: {} })], + [ + 'invalid run', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), currentRevisionId: 'missing-revision' }], + }), + ], + [ + 'inconsistent run status', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), status: 'failed' }], + }), + ], + [ + 'orphaned restart revision', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRestartedRun(), + revisions: [ + createRestartedRun().revisions[0], + { ...createRestartedRun().revisions[1], basedOnRevisionId: 'missing-revision' }, + ], + }, + ], + }), + ], + ])('ignores %s in localStorage', (_label, serialized) => { + const store = createWorkflowRunStore({ storage: new TestStorage(serialized) }) + + expect(store.get('run-1')).toBeNull() + }) + + it('keeps the memory snapshot and notifies subscribers when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + const listener = vi.fn() + const run = createRun() + + store.subscribe(run.id, listener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(listener).toHaveBeenCalledWith(run) + }) + + it('isolates subscriber values and stops notifications after unsubscribe', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + const unsubscribeFirst = store.subscribe(run.id, (savedRun) => { + savedRun.prompt = 'mutated by first listener' + }) + const unsubscribeSecond = store.subscribe(run.id, secondListener) + + store.save(run) + + expect(secondListener).toHaveBeenLastCalledWith(run) + expect(store.get(run.id)).toEqual(run) + + unsubscribeFirst() + unsubscribeSecond() + store.save({ ...run, prompt: 'new prompt' }) + + expect(secondListener).toHaveBeenCalledTimes(1) + }) + + it('does not let one failing subscriber block the saved state or other subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + + store.subscribe(run.id, () => { + throw new Error('render failed') + }) + store.subscribe(run.id, secondListener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(secondListener).toHaveBeenCalledWith(run) + }) + + it('uses the stable storage key by default', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ + storage: { + getItem: vi.fn(() => null), + setItem, + }, + }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 00000000..b85e1242 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,493 @@ +import type { WorkflowRun } from './index' +import { + parseCharacterTemplateGenerationResult, + parseCompleteAnimationGenerationResult, +} from '../generation' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 4 + +type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface WorkflowRunStore { + get(runId: WorkflowRun['id']): WorkflowRun | null + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** + * 传 null 可显式创建仅内存存储;不传时在浏览器中使用 localStorage。 + * 该入口也让纯逻辑测试无需模拟完整 DOM。 + */ + storage?: WorkflowRunStorage | null +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +function isWorkflowStep(value: unknown): boolean { + if (!isRecord(value)) return false + + const commonFieldsAreValid = + typeof value.id === 'string' && + isMember(value.type, WORKFLOW_STEP_ORDER) && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + isNullableString(value.taskId) && + isNullableString(value.submissionId) && + isStringArray(value.referenceStepIds) && + 'input' in value && + 'output' in value + if (!commonFieldsAreValid) return false + const error = value.error + if (!isNullableString(error)) return false + if ( + (value.status === 'failed' && (error === null || error.trim().length === 0)) || + (value.status !== 'failed' && error !== null) + ) { + return false + } + + if (value.type === 'character-setup') { + return ( + value.output === null && + (value.input === null || + (isRecord(value.input) && + typeof value.input.description === 'string' && + isStringArray(value.input.referenceMedia))) + ) + } + if (value.type === 'character-template') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'character_template' && + typeof value.input.projectId === 'string' && + typeof value.input.prompt === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCharacterTemplateGenerationResult(value.output) !== null) + ) + } + if (value.type === 'action-generation') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'complete_animation' && + typeof value.input.projectId === 'string' && + typeof value.input.characterId === 'string' && + typeof value.input.outfitId === 'string' && + typeof value.input.firstFrameUrl === 'string' && + typeof value.input.actionType === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCompleteAnimationGenerationResult(value.output) !== null) + ) + } + return true +} + +function isWorkflowRevision(value: unknown): boolean { + if (!isRecord(value)) return false + + return ( + typeof value.id === 'string' && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + Array.isArray(value.steps) && + value.steps.length === WORKFLOW_STEP_ORDER.length && + value.steps.every( + (step, index) => + isWorkflowStep(step) && isRecord(step) && step.type === WORKFLOW_STEP_ORDER[index], + ) && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +function isWorkflowRun(value: unknown): value is WorkflowRun { + if (!isRecord(value) || !Array.isArray(value.revisions)) return false + + const fieldsAreValid = + typeof value.id === 'string' && + typeof value.projectId === 'string' && + isNullableString(value.characterId) && + isNullableString(value.outfitId) && + isMember(value.purpose, WORKFLOW_PURPOSES) && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + typeof value.currentRevisionId === 'string' && + value.revisions.length > 0 && + value.revisions.every(isWorkflowRevision) && + value.revisions.some( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) && + isNullableString(value.prompt) + if (!fieldsAreValid) return false + + const currentRevision = value.revisions.find( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) + if (!isRecord(currentRevision) || !Array.isArray(currentRevision.steps)) return false + if (!hasValidRevisionLine(value.revisions)) return false + + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (currentRevision.status !== expectedRevisionStatus) return false + + const activeStepCount = currentRevision.steps.filter( + (step) => isRecord(step) && step.status === 'active', + ).length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + return value.revisions.every( + (revision) => + isRecord(revision) && + Array.isArray(revision.steps) && + revision.steps.every((step) => { + if (!isRecord(step)) return false + const taskId = step.taskId + const submissionId = step.submissionId + if (taskId !== null && submissionId !== null) return false + if (taskId === null && submissionId === null) return true + // 只有 character-template 与 action-generation 允许在 active 步骤上 + // 持有任务 ID(角色图 / 动作生成任务,刷新后可恢复轮询) + return ( + (step.type === 'character-template' || step.type === 'action-generation') && + step.status === 'active' + ) + }), + ) +} + +function hasValidRevisionLine(revisions: unknown[]): boolean { + const seenRevisionIds = new Set() + const byId = new Map>() + + for (const [index, revision] of revisions.entries()) { + if ( + !isRecord(revision) || + typeof revision.id !== 'string' || + seenRevisionIds.has(revision.id) + ) { + return false + } + seenRevisionIds.add(revision.id) + + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if ( + typeof revision.basedOnRevisionId !== 'string' || + typeof revision.restartStepId !== 'string' + ) { + return false + } + const source = byId.get(revision.basedOnRevisionId) + if ( + !source || + !Array.isArray(source.steps) || + !source.steps.some( + (step) => + isRecord(step) && step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + + byId.set(revision.id, revision) + } + + return true +} + +function migrateVersionOneRun(value: unknown): WorkflowRun | null { + if (!isRecord(value) || !Array.isArray(value.revisions)) return null + + const revisions: unknown[] = [] + for (const revision of value.revisions) { + const migratedRevision = migrateVersionOneRevision(revision) + if (!migratedRevision) return null + revisions.push(migratedRevision) + } + + const migrated = { ...value, revisions } + return migrateVersionThreeRun(migrated) +} + +function migrateVersionTwoRun(value: unknown): WorkflowRun | null { + return migrateVersionThreeRun(value) +} + +function migrateVersionThreeRun(value: unknown): WorkflowRun | null { + if (!isRecord(value) || !Array.isArray(value.revisions)) return null + const revisions = value.revisions.map((revision) => { + if (!isRecord(revision) || !Array.isArray(revision.steps)) return revision + return { + ...revision, + steps: revision.steps.map((step) => { + if (!isRecord(step) || step.type !== 'action-generation' || !isRecord(step.output)) { + return step + } + if (step.output.type !== 'first_frame' || !isRecord(step.output.image)) return step + const url = step.output.image.url + if (typeof url !== 'string' || !url) return step + const actionType = + isRecord(step.input) && + ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(step.input.actionType)) + ? step.input.actionType + : 'custom' + return { + ...step, + output: { + type: 'complete_animation', + actionType, + frames: [{ url, durationMs: null }], + }, + } + }), + } + }) + const migrated = { ...value, revisions } + return isWorkflowRun(migrated) ? migrated : null +} + +function migrateVersionOneRevision(value: unknown): Record | null { + if (!isRecord(value) || typeof value.id !== 'string' || !Array.isArray(value.steps)) return null + + const steps = value.steps + const legacyOrder = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', + ] as const + if ( + steps.length !== legacyOrder.length || + !steps.every((step, index) => isRecord(step) && step.type === legacyOrder[index]) + ) { + return null + } + + const [ + characterSetup, + characterTemplate, + templateCandidate, + actionSetup, + firstFrame, + animation, + review, + ] = steps + if ( + !isRecord(characterSetup) || + !isRecord(characterTemplate) || + !isRecord(templateCandidate) || + !isRecord(actionSetup) || + !isRecord(firstFrame) || + !isRecord(animation) || + !isRecord(review) + ) { + return null + } + + const actionSteps = [actionSetup, firstFrame, animation] + const collapsedAction = + actionSteps.find((step) => step.status === 'active') ?? + actionSteps.find((step) => step.status === 'failed') ?? + (actionSteps.every((step) => step.status === 'passed') ? animation : actionSetup) + const actionStepId = `${value.id}:action-generation` + const legacyActionIds = new Set( + actionSteps.map((step) => step.id).filter((id): id is string => typeof id === 'string'), + ) + + function migrateReferences(step: Record): Record { + const referenceStepIds = Array.isArray(step.referenceStepIds) + ? step.referenceStepIds.map((id) => (legacyActionIds.has(id) ? actionStepId : id)) + : step.referenceStepIds + return { ...step, referenceStepIds } + } + + return { + ...value, + steps: [ + migrateReferences(characterSetup), + migrateReferences(characterTemplate), + migrateReferences(templateCandidate), + { + ...migrateReferences(collapsedAction), + id: actionStepId, + type: 'action-generation', + }, + migrateReferences(review), + ], + } +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + + const persisted: unknown = JSON.parse(serialized) + if (!isRecord(persisted) || !Array.isArray(persisted.runs)) return [] + + if (persisted.version === WORKFLOW_RUN_STORAGE_VERSION) { + return persisted.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } + + if (persisted.version === 1) { + return persisted.runs + .map(migrateVersionOneRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + if (persisted.version === 2) { + return persisted.runs + .map(migrateVersionTwoRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + if (persisted.version === 3) { + return persisted.runs + .map(migrateVersionThreeRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + return [] + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + + try { + return window.localStorage + } catch { + return null + } +} + +/** + * WorkflowRun 的内存快照是当前会话的权威状态,localStorage 只负责刷新恢复。 + * 因此 save 先更新内存;浏览器拒绝写入时,本次运行仍能继续读取和订阅。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + const listListeners = new Set() + + return { + get(runId) { + const run = runs.get(runId) + return run === undefined ? null : structuredClone(run) + }, + + list() { + return [...runs.values()].map((run) => structuredClone(run)) + }, + + save(run) { + const savedRun = structuredClone(run) + runs.set(savedRun.id, savedRun) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 内存已成功更新;持久化失败不能中断当前会话中的工作流。 + } + + for (const listener of listeners.get(savedRun.id) ?? []) { + try { + listener(structuredClone(savedRun)) + } catch { + // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 + } + } + const snapshot = [...runs.values()].map((run) => structuredClone(run)) + for (const listener of listListeners) { + try { + listener(snapshot.map((run) => structuredClone(run))) + } catch { + // 一个列表订阅方失败不能阻断其他页面刷新。 + } + } + }, + + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, + } +} diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx new file mode 100644 index 00000000..c1b8609e --- /dev/null +++ b/frontend/src/features/auth-session/index.test.tsx @@ -0,0 +1,348 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { StrictMode, type ReactNode } from 'react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthTokens, User, UserApis } from '@/entities/user' +import { getApiAccessToken } from '@/shared/api' +import { + AuthSessionProvider, + ProtectedRoute, + createLocalUserApis, + resolveAuthMode, + useAuthSession, +} from '.' +import { REFRESH_TOKEN_STORAGE_KEY } from './session-storage' + +const user: User = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', +} + +let session: ReturnType | undefined + +function SessionProbe() { + session = useAuthSession() + return {session.state.status} +} + +function LocationProbe() { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + +function renderProvider(apis: UserApis, children: ReactNode = , strict = false) { + const tree = {children} + return render(strict ? {tree} : tree) +} + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + session = undefined + window.localStorage.clear() +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +describe('AuthSessionProvider', () => { + it('rotates the stored refresh token once in StrictMode, exposes it only through memory, then loads the current user', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const rotated = tokens('rotated-access', 'rotated-refresh') + const apis = createApis({ + refresh: vi.fn(async () => rotated), + me: vi.fn(async () => user), + }) + + renderProvider(apis, , true) + + await waitFor(() => + expect(screen.getByLabelText('session-status').textContent).toBe('authenticated'), + ) + expect(apis.refresh).toHaveBeenCalledTimes(1) + expect(apis.refresh).toHaveBeenCalledWith('stored-refresh') + expect(apis.me).toHaveBeenCalledTimes(1) + expect(getApiAccessToken()).toBe('rotated-access') + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh') + }) + + it('stores the rotated refresh token and keeps the access token in memory after login', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + expect(session?.state).toEqual({ status: 'authenticated', user }) + expect(getApiAccessToken()).toBe('login-access') + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh') + }) + + it('unregisters its in-memory access token provider when unmounted', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) }) + const view = renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + expect(getApiAccessToken()).toBe('login-access') + + view.unmount() + + expect(getApiAccessToken()).toBeUndefined() + }) + + it('falls back to a cleared guest session when startup token rotation fails', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'revoked-refresh') + const apis = createApis({ + refresh: vi.fn(async () => { + throw new Error('refresh revoked') + }), + }) + + renderProvider(apis) + + await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null })) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('does not restore a stale startup session after the user logs out', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const startupRefresh = deferred() + const apis = createApis({ refresh: vi.fn(() => startupRefresh.promise) }) + renderProvider(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('stored-refresh')) + + await act(async () => { + await session?.logout() + }) + await act(async () => { + startupRefresh.resolve(tokens('stale-access', 'stale-refresh')) + await startupRefresh.promise + }) + + await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null })) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('does not let a stale startup failure clear a newer login', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const startupMe = deferred() + const apis = createApis({ + refresh: vi.fn(async () => tokens('startup-access', 'startup-refresh')), + me: vi.fn(() => startupMe.promise), + login: vi.fn(async () => tokens('login-access', 'login-refresh')), + }) + renderProvider(apis) + await waitFor(() => expect(apis.me).toHaveBeenCalledTimes(1)) + + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + await act(async () => { + startupMe.reject(new Error('stale me failure')) + await Promise.resolve() + }) + + await waitFor(() => expect(session?.state).toEqual({ status: 'authenticated', user })) + expect(getApiAccessToken()).toBe('login-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh') + }) + + it('clears the local session before surfacing a backend logout failure', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const apis = createApis({ + refresh: vi.fn(async () => tokens('access', 'rotated-refresh')), + me: vi.fn(async () => user), + logout: vi.fn(async () => { + throw new Error('backend unavailable') + }), + }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('authenticated')) + + await act(async () => { + await expect(session?.logout()).rejects.toThrow('backend unavailable') + }) + + expect(session?.state).toEqual({ status: 'guest', user: null }) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + expect(apis.logout).toHaveBeenCalledWith('rotated-refresh') + }) + + it('clears the session after a successful password change because the backend revokes refresh tokens', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('access', 'refresh')) }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + await act(async () => { + await session?.changePassword({ oldPassword: 'password1', newPassword: 'password2' }) + }) + + expect(apis.changePassword).toHaveBeenCalledWith({ + oldPassword: 'password1', + newPassword: 'password2', + }) + expect(session?.state).toEqual({ status: 'guest', user: null }) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('refreshes an expiring JWT sixty seconds before expiry and rotates the refresh token', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-05T00:00:00Z')) + const expiringAccess = jwtExpiringAt(Date.now() + 120_000) + const refreshedAccess = jwtExpiringAt(Date.now() + 3_600_000) + const apis = createApis({ login: vi.fn(async () => tokens(expiringAccess, 'refresh-1')) }) + vi.mocked(apis.refresh).mockResolvedValue(tokens(refreshedAccess, 'refresh-2')) + renderProvider(apis) + await act(async () => Promise.resolve()) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + + expect(apis.refresh).toHaveBeenCalledWith('refresh-1') + expect(getApiAccessToken()).toBe(refreshedAccess) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-2') + }) + + it('exposes every account action as a Promise-returning operation', async () => { + const apis = createApis() + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + + await expect( + session?.sendCode({ email: 'ada@example.test', purpose: 'login' }), + ).resolves.toBeUndefined() + await expect( + session?.register({ email: 'ada@example.test', password: 'password1', code: '123456' }), + ).resolves.toEqual(tokens('access', 'refresh')) + await expect( + session?.loginByCode({ email: 'ada@example.test', code: '123456' }), + ).resolves.toEqual(tokens('access', 'refresh')) + }) +}) + +describe('resolveAuthMode', () => { + it('开发环境默认使用本地登录,生产环境始终使用真实后端认证', () => { + expect(resolveAuthMode('', true)).toBe('local') + expect(resolveAuthMode('local', true)).toBe('local') + expect(resolveAuthMode('backend', true)).toBe('backend') + expect(resolveAuthMode('local', false)).toBe('backend') + }) +}) + +describe('createLocalUserApis', () => { + it('只保存本地用户资料,不把密码和验证码写入浏览器存储,并可恢复会话', async () => { + const apis = createLocalUserApis() + const authenticated = await apis.register({ + email: 'ada@example.test', + password: 'password1', + code: '123456', + nickname: 'Ada', + }) + + expect(authenticated.user).toMatchObject({ + email: 'ada@example.test', + nickname: 'Ada', + status: 'normal', + }) + expect(JSON.stringify(window.localStorage)).not.toContain('password1') + expect(JSON.stringify(window.localStorage)).not.toContain('123456') + + await expect(createLocalUserApis().refresh(authenticated.refreshToken)).resolves.toMatchObject({ + user: authenticated.user, + }) + }) +}) + +describe('ProtectedRoute', () => { + it.each([ + [ + '/projects/7?tab=assets#frames', + '/?account=login&returnTo=%2Fprojects%2F7%3Ftab%3Dassets%23frames', + ], + ['//evil.example/path', '/?account=login'], + ])( + 'returns a guest from %s to the public login panel with only a safe same-site returnTo', + async (entry, expected) => { + const apis = createApis() + renderProvider( + apis, + + + +

Private

+ + } + /> + } /> +
+
, + ) + + await waitFor(() => expect(screen.getByLabelText('location').textContent).toBe(expected)) + expect(screen.queryByRole('heading', { name: 'Private' })).toBeNull() + }, + ) +}) + +function tokens(accessToken: string, refreshToken: string): AuthTokens { + return { accessToken, refreshToken, user } +} + +function jwtExpiringAt(expiryTime: number): string { + const payload = btoa(JSON.stringify({ exp: Math.floor(expiryTime / 1000) })) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') + return `header.${payload}.signature` +} + +function createApis(overrides: Partial = {}): UserApis { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens('access', 'refresh')), + login: vi.fn(async () => tokens('access', 'refresh')), + loginByCode: vi.fn(async () => tokens('access', 'refresh')), + refresh: vi.fn(async () => tokens('access', 'refresh')), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx new file mode 100644 index 00000000..07e51580 --- /dev/null +++ b/frontend/src/features/auth-session/index.tsx @@ -0,0 +1,396 @@ +/* oxlint-disable react/only-export-components -- 该模块的公共契约同时导出 Provider、路由守卫和 hook。 */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { Navigate, useLocation } from 'react-router' + +import type { AuthTokens, User, UserApis } from '@/entities/user' +import { registerApiAccessTokenProvider } from '@/shared/api' +import { clearRefreshToken, loadRefreshToken, saveRefreshToken } from './session-storage' + +export type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null } + | { status: 'authenticated'; user: User } + +export interface AuthSessionValue { + state: AuthSessionState + sendCode(input: Parameters[0]): Promise + register(input: Parameters[0]): Promise + login(input: Parameters[0]): Promise + loginByCode(input: Parameters[0]): Promise + changePassword(input: Parameters[0]): Promise + logout(): Promise +} + +export interface AuthSessionProviderProps { + apis: UserApis + children: ReactNode +} + +export type AuthMode = 'local' | 'backend' + +interface RefreshInFlight { + refreshToken: string + promise: Promise +} + +interface BootstrapResult { + user: User +} + +const AuthSessionContext = createContext(null) + +/** + * 本地开发默认使用浏览器内的开发登录,生产环境始终连接真实认证接口。 + * 开发者仍可通过 VITE_AUTH_MODE=backend 在本地完整调试登录流程。 + */ +export function resolveAuthMode( + value = import.meta.env.VITE_AUTH_MODE, + development = import.meta.env.DEV, +): AuthMode { + if (!development) return 'backend' + return value === 'backend' ? 'backend' : 'local' +} + +/** + * 按运行模式装配认证上下文;本地与后端适配器共用同一份会话逻辑。 + */ +export function AuthModeProvider({ apis, children }: { apis: UserApis; children: ReactNode }) { + return {children} +} + +const LOCAL_USER_STORAGE_KEY = 'windup.auth.local-user' + +/** + * 本地开发认证适配器。 + * + * 它只保存可展示的用户资料,不保存或校验密码、验证码;生产构建不会装配它。 + * 后端认证可用后只需切换 VITE_AUTH_MODE=backend,页面和会话逻辑无需重写。 + */ +export function createLocalUserApis(): UserApis { + let currentUser = readLocalUser() + + const authenticate = (email: string, nickname?: string): AuthTokens => { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) throw new Error('请输入邮箱') + currentUser = { + id: currentUser?.email === normalizedEmail ? currentUser.id : 1, + email: normalizedEmail, + nickname: nickname?.trim() || currentUser?.nickname || normalizedEmail.split('@')[0] || null, + emailVerifiedAt: currentUser?.emailVerifiedAt ?? new Date().toISOString(), + status: 'normal', + } + saveLocalUser(currentUser) + return localTokens(currentUser) + } + + return { + async sendCode() {}, + async register(input) { + return authenticate(input.email, input.nickname) + }, + async login(input) { + return authenticate(input.email) + }, + async loginByCode(input) { + return authenticate(input.email) + }, + async refresh(refreshToken) { + currentUser = readLocalUser() + if (!currentUser || refreshToken !== localRefreshToken(currentUser)) { + throw new Error('本地登录已失效') + } + return localTokens(currentUser) + }, + async logout() {}, + async me() { + currentUser = readLocalUser() + if (!currentUser) throw new Error('本地用户不存在') + return currentUser + }, + async changePassword() { + if (!currentUser) throw new Error('请先登录') + }, + } +} + +function localTokens(user: User): AuthTokens { + return { + accessToken: `local-access:${user.id}`, + refreshToken: localRefreshToken(user), + user, + } +} + +function localRefreshToken(user: User): string { + return `local-refresh:${user.id}` +} + +function readLocalUser(): User | null { + try { + const raw = globalThis.localStorage?.getItem(LOCAL_USER_STORAGE_KEY) + if (!raw) return null + const value: unknown = JSON.parse(raw) + if ( + !isRecord(value) || + typeof value.id !== 'number' || + typeof value.email !== 'string' || + (typeof value.nickname !== 'string' && value.nickname !== null) || + (typeof value.emailVerifiedAt !== 'string' && value.emailVerifiedAt !== null) || + (value.status !== 'normal' && value.status !== 'banned') + ) { + return null + } + return value as unknown as User + } catch { + return null + } +} + +function saveLocalUser(user: User): void { + globalThis.localStorage?.setItem(LOCAL_USER_STORAGE_KEY, JSON.stringify(user)) +} + +export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps) { + const [state, setState] = useState({ status: 'booting', user: null }) + const [accessTokenVersion, setAccessTokenVersion] = useState(0) + const accessTokenRef = useRef(null) + const refreshTokenRef = useRef(null) + const sessionGenerationRef = useRef(0) + const refreshInFlightRef = useRef(null) + const bootstrapPromiseRef = useRef | null>(null) + const bootstrapGenerationRef = useRef(null) + + const storeTokenMaterial = useCallback((tokens: AuthTokens) => { + accessTokenRef.current = tokens.accessToken + refreshTokenRef.current = tokens.refreshToken + saveRefreshToken(tokens.refreshToken) + setAccessTokenVersion((version) => version + 1) + }, []) + + const applyTokens = useCallback( + (tokens: AuthTokens) => { + sessionGenerationRef.current += 1 + storeTokenMaterial(tokens) + setState({ status: 'authenticated', user: tokens.user }) + }, + [storeTokenMaterial], + ) + + const clearSession = useCallback(() => { + sessionGenerationRef.current += 1 + accessTokenRef.current = null + refreshTokenRef.current = null + clearRefreshToken() + setAccessTokenVersion((version) => version + 1) + setState({ status: 'guest', user: null }) + }, []) + + const rotateTokens = useCallback( + (refreshToken: string): Promise => { + const inFlight = refreshInFlightRef.current + if (inFlight?.refreshToken === refreshToken) return inFlight.promise + + const promise = apis.refresh(refreshToken) + const current = { refreshToken, promise } + refreshInFlightRef.current = current + const clearInFlight = () => { + if (refreshInFlightRef.current === current) refreshInFlightRef.current = null + } + void promise.then(clearInFlight, clearInFlight) + return promise + }, + [apis], + ) + + useEffect(() => registerApiAccessTokenProvider(() => accessTokenRef.current), []) + + useEffect(() => { + let active = true + + if (!bootstrapPromiseRef.current) { + const bootstrapGeneration = sessionGenerationRef.current + bootstrapGenerationRef.current = bootstrapGeneration + const persistedRefreshToken = loadRefreshToken() + bootstrapPromiseRef.current = persistedRefreshToken + ? rotateTokens(persistedRefreshToken).then(async (tokens) => { + if (sessionGenerationRef.current !== bootstrapGeneration) return undefined + storeTokenMaterial(tokens) + const user = await apis.me() + if (sessionGenerationRef.current !== bootstrapGeneration) return undefined + return { user } + }) + : Promise.resolve(null) + } + + void bootstrapPromiseRef.current.then( + (result) => { + if ( + !active || + bootstrapGenerationRef.current !== sessionGenerationRef.current || + result === undefined + ) + return + if (!result) { + clearSession() + return + } + setState({ status: 'authenticated', user: result.user }) + }, + () => { + if (active && bootstrapGenerationRef.current === sessionGenerationRef.current) + clearSession() + }, + ) + + return () => { + active = false + } + }, [apis, clearSession, rotateTokens, storeTokenMaterial]) + + useEffect(() => { + const accessToken = accessTokenRef.current + const refreshAt = getRefreshTime(accessToken) + if (refreshAt === null) return + + let cancelled = false + let timer: ReturnType | undefined + + const schedule = () => { + const delay = Math.max(0, refreshAt - Date.now()) + timer = setTimeout( + () => { + if (cancelled) return + if (Date.now() < refreshAt) { + schedule() + return + } + + const refreshToken = refreshTokenRef.current + if (!refreshToken) return + void rotateTokens(refreshToken).then( + (tokens) => { + if (!cancelled && refreshTokenRef.current === refreshToken) applyTokens(tokens) + }, + () => { + if (!cancelled && refreshTokenRef.current === refreshToken) clearSession() + }, + ) + }, + Math.min(delay, 2_147_483_647), + ) + } + + schedule() + return () => { + cancelled = true + if (timer !== undefined) clearTimeout(timer) + } + }, [accessTokenVersion, applyTokens, clearSession, rotateTokens]) + + const sendCode = useCallback( + (input: Parameters[0]) => apis.sendCode(input), + [apis], + ) + + const register = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.register(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const login = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.login(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const loginByCode = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.loginByCode(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const changePassword = useCallback( + async (input: Parameters[0]) => { + await apis.changePassword(input) + clearSession() + }, + [apis, clearSession], + ) + + const logout = useCallback(async () => { + const refreshToken = refreshTokenRef.current + clearSession() + if (refreshToken) await apis.logout(refreshToken) + }, [apis, clearSession]) + + const value = useMemo( + () => ({ state, sendCode, register, login, loginByCode, changePassword, logout }), + [changePassword, login, loginByCode, logout, register, sendCode, state], + ) + + return {children} +} + +export function useAuthSession(): AuthSessionValue { + const session = useContext(AuthSessionContext) + if (!session) throw new Error('useAuthSession 必须在 AuthSessionProvider 内使用') + return session +} + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { state } = useAuthSession() + const location = useLocation() + + if (state.status === 'booting') return null + if (state.status === 'authenticated') return children + + const returnTo = `${location.pathname}${location.search}${location.hash}` + const loginTarget = isSafeReturnTo(returnTo) + ? `/?account=login&returnTo=${encodeURIComponent(returnTo)}` + : '/?account=login' + return +} + +function isSafeReturnTo(value: string): boolean { + return value.startsWith('/') && !value.startsWith('//') +} + +function getRefreshTime(accessToken: string | null): number | null { + if (!accessToken) return null + const payload = accessToken.split('.')[1] + if (!payload) return null + + try { + const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + const parsed: unknown = JSON.parse(globalThis.atob(padded)) + if (!isRecord(parsed) || typeof parsed.exp !== 'number' || !Number.isFinite(parsed.exp)) + return null + return parsed.exp * 1_000 - 60_000 + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts new file mode 100644 index 00000000..28af8652 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' + +import { + REFRESH_TOKEN_STORAGE_KEY, + clearRefreshToken, + loadRefreshToken, + saveRefreshToken, +} from './session-storage' + +afterEach(() => { + window.localStorage.clear() +}) + +describe('auth session storage', () => { + it('persists only the refresh token under the authentication key', () => { + saveRefreshToken('refresh-token') + + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-token') + expect(loadRefreshToken()).toBe('refresh-token') + }) + + it('removes the persisted refresh token', () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-token') + + clearRefreshToken() + + expect(loadRefreshToken()).toBeNull() + }) + + it('treats unavailable or failing local storage as an empty best-effort store', () => { + const failingStorage = { + getItem(): string | null { + throw new DOMException('blocked') + }, + setItem(): void { + throw new DOMException('blocked') + }, + removeItem(): void { + throw new DOMException('blocked') + }, + } + + expect(loadRefreshToken(failingStorage)).toBeNull() + expect(() => saveRefreshToken('refresh-token', failingStorage)).not.toThrow() + expect(() => clearRefreshToken(failingStorage)).not.toThrow() + }) +}) diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts new file mode 100644 index 00000000..06ba96ca --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.ts @@ -0,0 +1,40 @@ +export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token' + +type RefreshTokenStorage = Pick + +function getLocalStorage(): RefreshTokenStorage | null { + try { + return globalThis.localStorage + } catch { + return null + } +} + +export function loadRefreshToken( + storage: RefreshTokenStorage | null = getLocalStorage(), +): string | null { + try { + return storage?.getItem(REFRESH_TOKEN_STORAGE_KEY) ?? null + } catch { + return null + } +} + +export function saveRefreshToken( + refreshToken: string, + storage: RefreshTokenStorage | null = getLocalStorage(), +): void { + try { + storage?.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken) + } catch { + // 持久化不可用时仍保留当前内存会话。 + } +} + +export function clearRefreshToken(storage: RefreshTokenStorage | null = getLocalStorage()): void { + try { + storage?.removeItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + // 清理是尽力而为;浏览器禁用存储时不能让应用崩溃。 + } +} diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts new file mode 100644 index 00000000..60a05ecf --- /dev/null +++ b/frontend/src/features/character-setup/index.test.ts @@ -0,0 +1,10 @@ +import { expectTypeOf, it } from 'vitest' + +import type { CharacterSetupStepInput } from '@/entities' +import type { CharacterSetupProps } from '.' + +it('submits WorkflowRun character setup input', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf() +}) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts index 44c9a7b0..13827b1c 100644 --- a/frontend/src/features/character-setup/index.ts +++ b/frontend/src/features/character-setup/index.ts @@ -1,7 +1,7 @@ -import type { CreateCharacterInput } from '@/entities' +import type { CharacterSetupStepInput } from '@/entities' /** 填写角色资料并提交母版生成。 */ export interface CharacterSetupProps { projectId: string - onSubmit(input: CreateCharacterInput): void + onSubmit(input: CharacterSetupStepInput): void } diff --git a/frontend/src/features/export-package/README.md b/frontend/src/features/export-package/README.md new file mode 100644 index 00000000..d51b46be --- /dev/null +++ b/frontend/src/features/export-package/README.md @@ -0,0 +1,47 @@ +# Export Package 模块 + +本模块把已经通过质量检测的角色动作整理为可下载 ZIP。它不负责生成图片、保存历史记录或发布资产,只负责验证和导出。 + +## 数据怎么走 + +1. Playtest 或接口适配器组装 `ExportPackageModel`。 +2. `validateExportPackageModel` 检查角色、画布、生成记录、帧数、质量状态、锚点和脚底线。 +3. `createAssetExportPlan` 为每个动作方向生成稳定目录与三位连续帧名。 +4. `exportGameAssets` 读取透明 PNG,并检查图片尺寸是否与统一画布一致。 +5. 浏览器生成 Sprite Sheet,最后写入动画 `meta.json`、`schema.json`、README 与 ZIP。 +6. 可选 target 只在 `targets//` 下追加引擎文件,不修改通用层。 + +## 导出结构 + +```text +Aster-character-1/ + meta.json + schema.json + README.md + frames/Walk-south/Walk-south_000.png + atlas/Walk-south.png + targets//... +``` + +`meta.json` 的坐标原点在左上角,y 轴向下。`anchor` 是 0-1 归一化坐标,`foot_y` 是从画布顶部开始计算的像素值。 + +## 为什么缺一帧就全部失败 + +`expectedFrameCount` 表示后端声明的完整帧数,不能用 `frames.length` 自己推算。两者不同、图片读取失败、PNG 无透明信息、尺寸不一致或 `qualityStatus` 不是 `passed` 时,导出立即失败,不会用透明占位掩盖问题,也不会下载残缺包。 + +## Cocos Creator 边界 + +Issue #94 要求先用真实 Cocos Creator 3.x 验证图集切分数据、`.anim`、`.meta`、UUID 与小版本差异。当前仓库没有该实测结论,因此本模块只落地已确定的通用层和 target 扩展接口,不伪造 Cocos 原生文件。 + +Cocos 坐标转换规则已经明确:通用锚点 `(x, y)` 转成 Creator 锚点 `(x, 1-y)`。等实测字段回填后,只需新增一个 `AssetExportTarget`,不改 `meta.json` 与通用打包逻辑。 + +## 验证 + +```bash +npm test -- src/features/export-package +npm run typecheck +npm run lint +npm run build +``` + +测试覆盖 Schema 校验、连续命名、图集输出、缺帧失败、质量门禁、图片释放和空 target 扩展。 diff --git a/frontend/src/features/export-package/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts new file mode 100644 index 00000000..d135a773 --- /dev/null +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -0,0 +1,269 @@ +/** @vitest-environment jsdom */ +import Ajv2020 from 'ajv/dist/2020.js' +import { describe, expect, it, vi } from 'vitest' + +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportRuntime, + type AssetExportTarget, +} from './asset-export' +import { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +import { EXPORT_PACKAGE_JSON_SCHEMA_TEXT } from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel } from './model' + +function frame(index: number): ExportFrame { + return { + imageUrl: `/frames/walk-${index}.png`, + durationMs: 100, + rootMotion: { dx: index, dy: 0 }, + keyFrame: index === 0, + } +} + +function action(frameCount = 9): ExportAction { + return { + id: 'walk-abcdef12', + name: 'Walk / Forward', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + expectedFrameCount: frameCount, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + footY: 36, + qualityStatus: 'passed', + frames: Array.from({ length: frameCount }, (_, index) => frame(index)), + }, + ], + } +} + +const model: ExportPackageModel = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + actions: [action()], +} + +/** 构造足够让契约检查识别为 RGBA PNG 的文件头,解码由测试运行时接管。 */ +function rgbaPng(): Blob { + const data = new Uint8Array(33) + data.set([137, 80, 78, 71, 13, 10, 26, 10], 0) + new DataView(data.buffer).setUint32(8, 13, false) + data.set([73, 72, 68, 82], 12) + data[25] = 6 + return new Blob([data], { type: 'image/png' }) +} + +async function readStoredZip(blob: Blob): Promise> { + const data = new Uint8Array(await blob.arrayBuffer()) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + const decoder = new TextDecoder() + const entries = new Map() + let offset = 0 + + while (offset + 4 <= data.length && view.getUint32(offset, true) === 0x04034b50) { + const compressedSize = view.getUint32(offset + 18, true) + const nameLength = view.getUint16(offset + 26, true) + const extraLength = view.getUint16(offset + 28, true) + const nameStart = offset + 30 + const dataStart = nameStart + nameLength + extraLength + const name = decoder.decode(data.slice(nameStart, nameStart + nameLength)) + entries.set(name, data.slice(dataStart, dataStart + compressedSize)) + offset = dataStart + compressedSize + } + return entries +} + +function runtime(failingUrl: string | null = null): AssetExportRuntime { + return { + fetchFrame: vi.fn(async (url) => { + if (url === failingUrl) throw new Error('missing') + return rgbaPng() + }), + decodeFrame: vi.fn(async () => ({ + source: {} as CanvasImageSource, + width: 32, + height: 40, + close: vi.fn(), + })), + createCanvas: vi.fn((width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + Object.defineProperty(canvas, 'getContext', { + value: () => ({ + clearRect: vi.fn(), + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(width * height * 4) })), + }), + }) + Object.defineProperty(canvas, 'toBlob', { + value: (callback: BlobCallback) => callback(new Blob(['atlas'], { type: 'image/png' })), + }) + return canvas + }), + } +} + +describe('asset export', () => { + it('明确 Cocos 尚未就绪,并只落地已确认的锚点坐标转换', () => { + expect(COCOS_TARGET_READINESS.ready).toBe(false) + const anchor = toCocosAnchor({ x: 0.5, y: 0.9 }) + expect(anchor.x).toBe(0.5) + expect(anchor.y).toBeCloseTo(0.1) + }) + + it('按动作名和方向生成连续三位帧名,并按八列排列图集', () => { + const plan = createAssetExportPlan(model) + + expect(plan).toHaveLength(1) + expect(plan[0]).toMatchObject({ + exportName: 'Walk-Forward-south', + framesFolder: 'frames/Walk-Forward-south', + atlasFile: 'atlas/Walk-Forward-south.png', + columns: 8, + rows: 2, + }) + expect(plan[0]?.frames[0]?.filename).toBe('Walk-Forward-south_000.png') + expect(plan[0]?.frames[8]?.filename).toBe('Walk-Forward-south_008.png') + }) + + it('生成通用目录、透明 PNG、图集、README、Schema 和可校验的动画 meta.json', async () => { + const phases: string[] = [] + const result = await exportGameAssets(model, { + runtime: runtime(), + onPhase: (phase) => phases.push(phase), + }) + const entries = await readStoredZip(result.blob) + const root = 'Aster-character-1' + const names = [...entries.keys()] + + expect(result.filename).toBe('windup-Aster-character-1.zip') + expect(phases).toEqual(['validating', 'collecting', 'rendering', 'packing']) + expect(names).toContain(`${root}/meta.json`) + expect(names).toContain(`${root}/schema.json`) + expect(names).toContain(`${root}/README.md`) + expect(names).toContain(`${root}/atlas/Walk-Forward-south.png`) + expect(names.filter((name) => name.includes('/frames/'))).toHaveLength(9) + + const meta = JSON.parse(new TextDecoder().decode(entries.get(`${root}/meta.json`))) + const schema = JSON.parse(EXPORT_PACKAGE_JSON_SCHEMA_TEXT) + const validate = new Ajv2020().compile(schema) + expect(validate(meta), JSON.stringify(validate.errors)).toBe(true) + expect(meta).toMatchObject({ + schema_version: '1.0.0', + character: { id: 'character-1', name: 'Aster' }, + canvas: { w: 32, h: 40 }, + source: { workflow_run_id: 'run-1', generation_ids: ['generation-1'] }, + }) + expect(meta.actions[0]).toMatchObject({ + name: 'Walk-Forward-south', + fps: 10, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + foot_y: 36, + atlas: { cols: 8, rows: 2, cell: { w: 32, h: 40 } }, + }) + expect(names.some((name) => name.endsWith('.gif'))).toBe(false) + }) + + it('声明帧数与实际帧数不一致时,在读取图片前拒绝导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, expectedFrameCount: 10 }], + }, + ], + } + const testRuntime = runtime() + + await expect(exportGameAssets(badModel, { runtime: testRuntime })).rejects.toThrow( + 'actions[0].sequences[0].frames: 缺帧,期望 10 帧,实际 9 帧', + ) + expect(testRuntime.fetchFrame).not.toHaveBeenCalled() + }) + + it('任一原图读取失败时拒绝整个导出,不再生成透明占位包', async () => { + await expect( + exportGameAssets(model, { runtime: runtime('/frames/walk-4.png') }), + ).rejects.toThrow('frames/Walk-Forward-south/Walk-Forward-south_004.png: 图片读取失败') + }) + + it('质量状态未通过时禁止导出', async () => { + const badModel: ExportPackageModel = { + ...model, + actions: [ + { + ...action(), + sequences: [{ ...action().sequences[0]!, qualityStatus: 'pending' }], + }, + ], + } + + await expect(exportGameAssets(badModel, { runtime: runtime() })).rejects.toThrow( + 'actions[0].sequences[0].qualityStatus: 质量检测未通过,禁止导出', + ) + }) + + it('空 target 不改变通用层,新增 target 文件只进入自己的目录', async () => { + const emptyTarget: AssetExportTarget = { + id: 'empty', + createFiles: vi.fn(async () => []), + } + const cocosProbe: AssetExportTarget = { + id: 'cocos-probe', + createFiles: vi.fn(async ({ metadata }) => [ + { path: 'anchor-map.json', data: JSON.stringify({ source: metadata.actions[0]?.anchor }) }, + ]), + } + const common = await readStoredZip((await exportGameAssets(model, { runtime: runtime() })).blob) + const extended = await readStoredZip( + (await exportGameAssets(model, { runtime: runtime(), targets: [emptyTarget, cocosProbe] })) + .blob, + ) + const targetPath = 'Aster-character-1/targets/cocos-probe/anchor-map.json' + + expect([...extended.keys()].filter((name) => !name.includes('/targets/'))).toEqual([ + ...common.keys(), + ]) + expect(extended.has(targetPath)).toBe(true) + expect(emptyTarget.createFiles).toHaveBeenCalledTimes(1) + }) + + it('渲染失败时释放已经解码的全部图片', async () => { + const baseRuntime = runtime() + const closes: Array> = [] + const failingRuntime: AssetExportRuntime = { + ...baseRuntime, + decodeFrame: vi.fn(async () => { + const close = vi.fn() + closes.push(close) + return { source: {} as CanvasImageSource, width: 32, height: 40, close } + }), + createCanvas: vi.fn((width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + Object.defineProperty(canvas, 'getContext', { value: () => null }) + return canvas + }), + } + + await expect(exportGameAssets(model, { runtime: failingRuntime })).rejects.toThrow( + 'atlas/Walk-Forward-south.png: 浏览器无法创建 2D 画布', + ) + expect(closes).toHaveLength(9) + expect(closes.every((close) => close.mock.calls.length === 1)).toBe(true) + }) +}) diff --git a/frontend/src/features/export-package/asset-export.ts b/frontend/src/features/export-package/asset-export.ts new file mode 100644 index 00000000..0d573c7d --- /dev/null +++ b/frontend/src/features/export-package/asset-export.ts @@ -0,0 +1,534 @@ +import { + EXPORT_PACKAGE_JSON_SCHEMA_TEXT, + EXPORT_PACKAGE_SCHEMA_VERSION, + type GenericExportMetadata, + validateExportPackageModel, +} from './contract' +import type { ExportAction, ExportFrame, ExportPackageModel, ExportSequence } from './model' + +export type AssetExportPhase = 'validating' | 'collecting' | 'rendering' | 'packing' + +export interface AssetExportResult { + blob: Blob + filename: string +} + +export interface DecodedFrame { + source: CanvasImageSource + width: number + height: number + close(): void +} + +export interface AssetExportRuntime { + fetchFrame(url: string): Promise + decodeFrame(blob: Blob): Promise + createCanvas(width: number, height: number): HTMLCanvasElement +} + +export interface PlannedFrame { + frame: ExportFrame + index: number + filename: string + relativeFile: string +} + +export interface PlannedSequence { + action: ExportAction + sequence: ExportSequence + exportName: string + framesFolder: string + atlasFile: string + columns: number + rows: number + frames: readonly PlannedFrame[] +} + +export interface AssetExportTargetFile { + /** 相对于 targets// 的路径。 */ + path: string + data: Blob | string | Uint8Array +} + +export interface AssetExportTargetContext { + model: ExportPackageModel + metadata: GenericExportMetadata + plan: readonly PlannedSequence[] +} + +/** 新引擎只实现 target,不应修改通用 meta.json、frames 与 atlas。 */ +export interface AssetExportTarget { + id: string + createFiles(context: AssetExportTargetContext): Promise +} + +export interface ExportGameAssetsOptions { + runtime?: AssetExportRuntime + targets?: readonly AssetExportTarget[] + onPhase?: (phase: AssetExportPhase) => void +} + +interface LoadedFrame extends PlannedFrame { + data: Uint8Array + decoded: DecodedFrame +} + +interface LoadedSequence { + item: PlannedSequence + frames: readonly LoadedFrame[] +} + +interface ZipEntry { + name: string + data: Uint8Array +} + +function safeSegment(value: string, fallback: string): string { + const normalized = value + .normalize('NFKC') + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + return normalized || fallback +} + +function idSuffix(id: string): string { + return safeSegment(id, 'id').slice(-8) || 'id' +} + +function packageRoot(model: ExportPackageModel): string { + return `${safeSegment(model.characterName, 'character')}-${safeSegment(model.characterId, 'id')}` +} + +function uniqueActionName( + action: ExportAction, + sequence: ExportSequence, + usedNames: Set, +): string { + const baseName = safeSegment(action.name, 'action') + const direction = safeSegment(sequence.direction, 'default') + const candidate = direction === 'default' ? baseName : `${baseName}-${direction}` + const unique = usedNames.has(candidate) ? `${candidate}-${idSuffix(action.id)}` : candidate + if (usedNames.has(unique)) throw new Error(`actions.name: 导出动作名重复:${unique}`) + usedNames.add(unique) + return unique +} + +export function createAssetExportPlan(model: ExportPackageModel): readonly PlannedSequence[] { + const usedNames = new Set() + return model.actions.flatMap((action) => + action.sequences.flatMap((sequence) => { + if (sequence.frames.length === 0) return [] + const exportName = uniqueActionName(action, sequence, usedNames) + const columns = Math.min(8, sequence.frames.length) + return [ + { + action, + sequence, + exportName, + framesFolder: `frames/${exportName}`, + atlasFile: `atlas/${exportName}.png`, + columns, + rows: Math.ceil(sequence.frames.length / columns), + frames: sequence.frames.map((currentFrame, index) => { + const filename = `${exportName}_${String(index).padStart(3, '0')}.png` + return { + frame: currentFrame, + index, + filename, + relativeFile: `frames/${exportName}/${filename}`, + } + }), + }, + ] + }), + ) +} + +const defaultRuntime: AssetExportRuntime = { + async fetchFrame(url) { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.blob() + }, + async decodeFrame(blob) { + const bitmap = await createImageBitmap(blob) + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + close: () => bitmap.close(), + } + }, + createCanvas(width, height) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas + }, +} + +function canvasPng(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob === null) reject(new Error('atlas: PNG 编码失败')) + else resolve(blob) + }, 'image/png') + }) +} + +async function bytes(data: Blob | string | Uint8Array): Promise { + if (typeof data === 'string') return new TextEncoder().encode(data) + if (data instanceof Uint8Array) return data + return new Uint8Array(await data.arrayBuffer()) +} + +function hasPngSignature(data: Uint8Array): boolean { + const signature = [137, 80, 78, 71, 13, 10, 26, 10] + return signature.every((value, index) => data[index] === value) +} + +/** + * MIME 只能说明“服务端声称是 PNG”,不能证明文件真的可用或带透明信息。 + * 因此这里读取 PNG 头,并兼容 RGBA、灰度 Alpha 与带 tRNS 块的索引 PNG。 + */ +function assertPngWithAlpha(data: Uint8Array, field: string): void { + if (data.length < 33 || !hasPngSignature(data)) throw new Error(`${field}: 必须是有效 PNG`) + const colorType = data[25] + if (colorType === 4 || colorType === 6) return + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + let offset = 8 + while (offset + 12 <= data.length) { + const chunkLength = view.getUint32(offset, false) + const chunkEnd = offset + 12 + chunkLength + if (chunkEnd > data.length) break + const chunkName = String.fromCharCode(...data.slice(offset + 4, offset + 8)) + if (chunkName === 'tRNS') return + if (chunkName === 'IEND') break + offset = chunkEnd + } + throw new Error(`${field}: PNG 必须包含 Alpha 透明通道`) +} + +async function loadFrame( + planned: PlannedFrame, + runtime: AssetExportRuntime, + cache: Map>, +): Promise { + const field = `${planned.relativeFile}` + let pending = cache.get(planned.frame.imageUrl) + if (pending === undefined) { + pending = runtime.fetchFrame(planned.frame.imageUrl) + cache.set(planned.frame.imageUrl, pending) + } + + let blob: Blob + try { + blob = await pending + } catch (error) { + const reason = error instanceof Error ? error.message : '未知错误' + throw new Error(`${field}: 图片读取失败(${reason})`) + } + if (blob.type.toLowerCase() !== 'image/png') throw new Error(`${field}: 文件类型必须是 image/png`) + const data = await bytes(blob) + assertPngWithAlpha(data, field) + + let decoded: DecodedFrame + try { + decoded = await runtime.decodeFrame(blob) + } catch (error) { + const reason = error instanceof Error ? error.message : '未知错误' + throw new Error(`${field}: PNG 解码失败(${reason})`) + } + return { ...planned, data, decoded } +} + +async function loadAllFrames( + plan: readonly PlannedSequence[], + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const cache = new Map>() + const references = plan.flatMap((item) => item.frames.map((frame) => ({ item, frame }))) + const settled = await Promise.allSettled( + references.map(async ({ item, frame }) => { + const loaded = await loadFrame(frame, runtime, cache) + if ( + loaded.decoded.width !== model.canvas.width || + loaded.decoded.height !== model.canvas.height + ) { + loaded.decoded.close() + throw new Error( + `${frame.relativeFile}: 画布应为 ${model.canvas.width}x${model.canvas.height},实际为 ${loaded.decoded.width}x${loaded.decoded.height}`, + ) + } + return { item, loaded } + }), + ) + + const fulfilled = settled.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ) + const failure = settled.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') { + fulfilled.forEach(({ loaded }) => loaded.decoded.close()) + throw failure.reason + } + + return plan.map((item) => ({ + item, + frames: fulfilled.filter((result) => result.item === item).map((result) => result.loaded), + })) +} + +function context2d(canvas: HTMLCanvasElement, field: string): CanvasRenderingContext2D { + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (context === null) throw new Error(`${field}: 浏览器无法创建 2D 画布`) + return context +} + +async function renderAtlas( + loaded: LoadedSequence, + model: ExportPackageModel, + runtime: AssetExportRuntime, +): Promise { + const canvas = runtime.createCanvas( + model.canvas.width * loaded.item.columns, + model.canvas.height * loaded.item.rows, + ) + const context = context2d(canvas, loaded.item.atlasFile) + context.clearRect(0, 0, canvas.width, canvas.height) + loaded.frames.forEach((frame) => { + const column = frame.index % loaded.item.columns + const row = Math.floor(frame.index / loaded.item.columns) + context.drawImage(frame.decoded.source, column * model.canvas.width, row * model.canvas.height) + }) + return canvasPng(canvas) +} + +function createMetadata( + model: ExportPackageModel, + plan: readonly PlannedSequence[], +): GenericExportMetadata { + return { + schema_version: EXPORT_PACKAGE_SCHEMA_VERSION, + character: { id: model.characterId, name: model.characterName }, + canvas: { w: model.canvas.width, h: model.canvas.height }, + actions: plan.map((item) => ({ + name: item.exportName, + fps: item.action.fps, + loop: item.sequence.loop, + frames: item.frames.map((frame) => ({ index: frame.index, file: frame.filename })), + anchor: { ...item.sequence.anchor }, + foot_y: item.sequence.footY, + atlas: { + file: item.atlasFile, + cols: item.columns, + rows: item.rows, + cell: { w: model.canvas.width, h: model.canvas.height }, + }, + })), + source: + model.source === null + ? null + : { + workflow_run_id: model.source.workflowRunId, + generation_ids: [...model.source.generationIds], + }, + } +} + +function createReadme(model: ExportPackageModel): string { + return `# ${model.characterName} 导出包 + +这是 Windup 通用资产包,契约版本为 ${EXPORT_PACKAGE_SCHEMA_VERSION}。 + +## 内容 + +- \`meta.json\`: 动作、帧率、循环、画布、锚点、脚底线、图集与生成记录。 +- \`frames//\`: 连续编号的透明 PNG 原始帧。 +- \`atlas/.png\`: 按 \`meta.json\` 中 cols、rows 和 cell 切分的图集。 +- \`schema.json\`: 校验 \`meta.json\` 的 JSON Schema。 +- \`targets//\`: 可选引擎适配器产生的原生文件。 + +## 坐标 + +通用层原点在画布左上角,y 轴向下;anchor 的 x/y 都是 0 到 1。 +引擎 target 负责坐标换算。例如 Cocos Creator 使用左下角原点,需要换算为 (x, 1-y)。 + +## Cocos Creator 状态 + +本包没有伪造 .anim 或 .meta。Issue #94 要求先在真实 Creator 3.x 中确认图集切分、UUID 和版本格式; +验证完成前只能使用通用 frames、atlas 和 meta.json,不能声称“拖入即播放”。 +` +} + +function safeTargetPath(targetId: string, path: string, index: number): string { + const normalized = path.replace(/\\/g, '/') + if ( + normalized.length === 0 || + normalized.startsWith('/') || + normalized.split('/').some((segment) => segment === '..' || segment === '') + ) { + throw new Error(`targets.${targetId}.files[${index}].path: 必须是安全的相对路径`) + } + return `targets/${safeSegment(targetId, 'target')}/${normalized}` +} + +function uint32Table(): Uint32Array { + const table = new Uint32Array(256) + for (let value = 0; value < 256; value += 1) { + let current = value + for (let bit = 0; bit < 8; bit += 1) { + current = (current & 1) !== 0 ? 0xedb88320 ^ (current >>> 1) : current >>> 1 + } + table[value] = current >>> 0 + } + return table +} + +const CRC32_TABLE = uint32Table() + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff + for (const value of data) crc = (crc >>> 8) ^ (CRC32_TABLE[(crc ^ value) & 0xff] ?? 0) + return (crc ^ 0xffffffff) >>> 0 +} + +function dosDateTime(date: Date): { date: number; time: number } { + const year = Math.max(1980, date.getFullYear()) + return { + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + } +} + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)) + let offset = 0 + for (const chunk of chunks) { + output.set(chunk, offset) + offset += chunk.length + } + return output +} + +function storedZip(entries: readonly ZipEntry[]): Blob { + const localChunks: Uint8Array[] = [] + const centralChunks: Uint8Array[] = [] + const encoder = new TextEncoder() + const timestamp = dosDateTime(new Date()) + let localOffset = 0 + + for (const entry of entries) { + const name = encoder.encode(entry.name) + const checksum = crc32(entry.data) + const local = new Uint8Array(30 + name.length) + const localView = new DataView(local.buffer) + localView.setUint32(0, 0x04034b50, true) + localView.setUint16(4, 20, true) + localView.setUint16(6, 0x0800, true) + localView.setUint16(8, 0, true) + localView.setUint16(10, timestamp.time, true) + localView.setUint16(12, timestamp.date, true) + localView.setUint32(14, checksum, true) + localView.setUint32(18, entry.data.length, true) + localView.setUint32(22, entry.data.length, true) + localView.setUint16(26, name.length, true) + local.set(name, 30) + localChunks.push(local, entry.data) + + const central = new Uint8Array(46 + name.length) + const centralView = new DataView(central.buffer) + centralView.setUint32(0, 0x02014b50, true) + centralView.setUint16(4, 20, true) + centralView.setUint16(6, 20, true) + centralView.setUint16(8, 0x0800, true) + centralView.setUint16(10, 0, true) + centralView.setUint16(12, timestamp.time, true) + centralView.setUint16(14, timestamp.date, true) + centralView.setUint32(16, checksum, true) + centralView.setUint32(20, entry.data.length, true) + centralView.setUint32(24, entry.data.length, true) + centralView.setUint16(28, name.length, true) + centralView.setUint32(42, localOffset, true) + central.set(name, 46) + centralChunks.push(central) + localOffset += local.length + entry.data.length + } + + const centralDirectory = concat(centralChunks) + const end = new Uint8Array(22) + const endView = new DataView(end.buffer) + endView.setUint32(0, 0x06054b50, true) + endView.setUint16(8, entries.length, true) + endView.setUint16(10, entries.length, true) + endView.setUint32(12, centralDirectory.length, true) + endView.setUint32(16, localOffset, true) + const output = concat([...localChunks, centralDirectory, end]) + const buffer = new ArrayBuffer(output.length) + new Uint8Array(buffer).set(output) + return new Blob([buffer], { type: 'application/zip' }) +} + +export async function exportGameAssets( + model: ExportPackageModel, + options: ExportGameAssetsOptions = {}, +): Promise { + const runtime = options.runtime ?? defaultRuntime + options.onPhase?.('validating') + validateExportPackageModel(model) + const plan = createAssetExportPlan(model) + const metadata = createMetadata(model, plan) + + options.onPhase?.('collecting') + const loaded = await loadAllFrames(plan, model, runtime) + const root = packageRoot(model) + const entries: ZipEntry[] = [] + + try { + options.onPhase?.('rendering') + for (const current of loaded) { + for (const frame of current.frames) { + entries.push({ name: `${root}/${frame.relativeFile}`, data: frame.data }) + } + entries.push({ + name: `${root}/${current.item.atlasFile}`, + data: await bytes(await renderAtlas(current, model, runtime)), + }) + } + + entries.push( + { + name: `${root}/meta.json`, + data: await bytes(JSON.stringify(metadata, null, 2)), + }, + { name: `${root}/schema.json`, data: await bytes(EXPORT_PACKAGE_JSON_SCHEMA_TEXT) }, + { name: `${root}/README.md`, data: await bytes(createReadme(model)) }, + ) + + for (const target of options.targets ?? []) { + const targetId = safeSegment(target.id, 'target') + const files = await target.createFiles({ model, metadata, plan }) + for (const [index, file] of files.entries()) { + entries.push({ + name: `${root}/${safeTargetPath(targetId, file.path, index)}`, + data: await bytes(file.data), + }) + } + } + } finally { + loaded.forEach(({ frames }) => frames.forEach((frame) => frame.decoded.close())) + } + + const duplicate = entries.find( + (entry, index) => entries.findIndex((item) => item.name === entry.name) !== index, + ) + if (duplicate !== undefined) throw new Error(`package.files: 文件路径重复:${duplicate.name}`) + + options.onPhase?.('packing') + return { + blob: storedZip(entries), + filename: `windup-${root}.zip`, + } +} diff --git a/frontend/src/features/export-package/cocos-target.ts b/frontend/src/features/export-package/cocos-target.ts new file mode 100644 index 00000000..5085f2a1 --- /dev/null +++ b/frontend/src/features/export-package/cocos-target.ts @@ -0,0 +1,15 @@ +import type { ExportAnchor } from './model' + +/** + * Issue #94 的 Cocos 原生文件格式仍等待真实 Creator 3.x 实测。 + * 这里公开状态,避免页面或调用方把通用包误标成“Cocos 拖入即用包”。 + */ +export const COCOS_TARGET_READINESS = { + ready: false, + reason: '等待真实 Cocos Creator 3.x 验证 .anim、.meta、UUID 与图集切分格式', +} as const + +/** 通用层左上原点转为 Cocos Creator 左下原点;x 不变,y 上下翻转。 */ +export function toCocosAnchor(anchor: ExportAnchor): ExportAnchor { + return { x: anchor.x, y: 1 - anchor.y } +} diff --git a/frontend/src/features/export-package/contract.ts b/frontend/src/features/export-package/contract.ts new file mode 100644 index 00000000..8883a03c --- /dev/null +++ b/frontend/src/features/export-package/contract.ts @@ -0,0 +1,113 @@ +import exportSchemaText from './export-package.schema.json?raw' +import type { ExportPackageModel } from './model' + +export const EXPORT_PACKAGE_SCHEMA_VERSION = '1.0.0' +export const EXPORT_PACKAGE_JSON_SCHEMA_TEXT = exportSchemaText + +export interface GenericExportFrame { + index: number + file: string +} + +export interface GenericExportAction { + name: string + fps: number + loop: boolean + frames: readonly GenericExportFrame[] + anchor: { x: number; y: number } + foot_y: number + atlas: { + file: string + cols: number + rows: number + cell: { w: number; h: number } + } +} + +export interface GenericExportMetadata { + schema_version: typeof EXPORT_PACKAGE_SCHEMA_VERSION + character: { id: string; name: string } + canvas: { w: number; h: number } + actions: readonly GenericExportAction[] + source: { + workflow_run_id: string + generation_ids: readonly string[] + } | null +} + +function fail(field: string, reason: string): never { + throw new Error(`${field}: ${reason}`) +} + +function requireText(field: string, value: string): void { + if (typeof value !== 'string' || value.trim().length === 0) fail(field, '必须是非空字符串') +} + +function requirePositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) fail(field, '必须是大于 0 的整数') +} + +function requireUnitNumber(field: string, value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) fail(field, '必须是 0 到 1 的数值') +} + +/** + * 在读取任何图片前完成结构与质量门禁。 + * 报错路径使用 meta.json 对应字段名,方便调用方直接定位坏数据。 + */ +export function validateExportPackageModel(model: ExportPackageModel): void { + requireText('character.id', model.characterId) + requireText('character.name', model.characterName) + requireText('outfit.id', model.outfitId) + requireText('outfit.name', model.outfitName) + requirePositiveInteger('canvas.w', model.canvas.width) + requirePositiveInteger('canvas.h', model.canvas.height) + if (model.source !== null) { + requireText('source.workflow_run_id', model.source.workflowRunId) + const generationIds = new Set() + model.source.generationIds.forEach((id, index) => { + requireText(`source.generation_ids[${index}]`, id) + if (generationIds.has(id)) fail(`source.generation_ids[${index}]`, '生成记录不能重复') + generationIds.add(id) + }) + } + + if (model.actions.length === 0) fail('actions', '至少需要一个动作') + model.actions.forEach((action, actionIndex) => { + const actionField = `actions[${actionIndex}]` + requireText(`${actionField}.name`, action.name) + requirePositiveInteger(`${actionField}.fps`, action.fps) + if (action.sequences.length === 0) fail(`${actionField}.sequences`, '至少需要一个动作方向') + + action.sequences.forEach((sequence, sequenceIndex) => { + const sequenceField = `${actionField}.sequences[${sequenceIndex}]` + requireText(`${sequenceField}.direction`, sequence.direction) + requirePositiveInteger(`${sequenceField}.expectedFrameCount`, sequence.expectedFrameCount) + requireUnitNumber(`${sequenceField}.anchor.x`, sequence.anchor.x) + requireUnitNumber(`${sequenceField}.anchor.y`, sequence.anchor.y) + if ( + !Number.isInteger(sequence.footY) || + sequence.footY < 0 || + sequence.footY > model.canvas.height + ) { + fail(`${sequenceField}.footY`, `必须是 0 到 ${model.canvas.height} 的整数像素值`) + } + if (sequence.qualityStatus !== 'passed') { + fail(`${sequenceField}.qualityStatus`, '质量检测未通过,禁止导出') + } + if (sequence.frames.length !== sequence.expectedFrameCount) { + fail( + `${sequenceField}.frames`, + `缺帧,期望 ${sequence.expectedFrameCount} 帧,实际 ${sequence.frames.length} 帧`, + ) + } + sequence.frames.forEach((frame, frameIndex) => { + requireText(`${sequenceField}.frames[${frameIndex}].imageUrl`, frame.imageUrl) + requirePositiveInteger( + `${sequenceField}.frames[${frameIndex}].durationMs`, + frame.durationMs, + ) + }) + }) + }) +} diff --git a/frontend/src/features/export-package/export-package.schema.json b/frontend/src/features/export-package/export-package.schema.json new file mode 100644 index 00000000..78b4536f --- /dev/null +++ b/frontend/src/features/export-package/export-package.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://windup.local/schemas/export-package-1.0.0.json", + "title": "Windup Generic Export Package", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "character", "canvas", "actions", "source"], + "properties": { + "schema_version": { "const": "1.0.0" }, + "character": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 } + } + }, + "canvas": { + "type": "object", + "additionalProperties": false, + "required": ["w", "h"], + "properties": { + "w": { "type": "integer", "minimum": 1 }, + "h": { "type": "integer", "minimum": 1 } + } + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "fps", "loop", "frames", "anchor", "foot_y", "atlas"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "fps": { "type": "integer", "minimum": 1 }, + "loop": { "type": "boolean" }, + "frames": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["index", "file"], + "properties": { + "index": { "type": "integer", "minimum": 0 }, + "file": { "type": "string", "pattern": "^[^/]+_[0-9]{3}\\.png$" } + } + } + }, + "anchor": { + "type": "object", + "additionalProperties": false, + "required": ["x", "y"], + "properties": { + "x": { "type": "number", "minimum": 0, "maximum": 1 }, + "y": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "foot_y": { "type": "integer", "minimum": 0 }, + "atlas": { + "type": "object", + "additionalProperties": false, + "required": ["file", "cols", "rows", "cell"], + "properties": { + "file": { "type": "string", "pattern": "^atlas/.+\\.png$" }, + "cols": { "type": "integer", "minimum": 1 }, + "rows": { "type": "integer", "minimum": 1 }, + "cell": { + "type": "object", + "additionalProperties": false, + "required": ["w", "h"], + "properties": { + "w": { "type": "integer", "minimum": 1 }, + "h": { "type": "integer", "minimum": 1 } + } + } + } + } + } + } + }, + "source": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["workflow_run_id", "generation_ids"], + "properties": { + "workflow_run_id": { "type": "string", "minLength": 1 }, + "generation_ids": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + ] + } + } +} diff --git a/frontend/src/features/export-package/export-panel.test.tsx b/frontend/src/features/export-package/export-panel.test.tsx new file mode 100644 index 00000000..fe37af00 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -0,0 +1,121 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ExportPackageModel } from './model' +import { ExportPanel } from './export-panel' + +const model = { + characterId: 'character-1', + characterName: 'Aster', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: null, + baseFrameCount: 0, + canvas: { width: 32, height: 40 }, + source: { workflowRunId: 'run-1', generationIds: ['generation-1'] }, + actions: [ + { + id: 'walk-abcdef12', + name: 'Walk', + type: 'walk', + fps: 10, + sequences: [ + { + direction: 'south', + expectedFrameCount: 1, + loop: true, + anchor: { x: 0.5, y: 0.9 }, + footY: 36, + qualityStatus: 'passed', + frames: [ + { + imageUrl: '/walk.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + ], +} satisfies ExportPackageModel + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ExportPanel', () => { + it('质量问题会阻止导出,而不是只显示警告', () => { + render() + + expect(screen.getByText('当前有 3 项质量问题,全部通过后才能导出')).toBeTruthy() + expect( + (screen.getByRole('button', { name: '导出游戏资产包' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('显示进度、阻止重复点击、下载后释放临时地址', async () => { + let resolveExport: (value: { blob: Blob; filename: string }) => void = () => { + throw new Error('export promise was not initialized') + } + const exporter = vi.fn( + ( + _model: ExportPackageModel, + onPhase?: (phase: 'validating' | 'collecting' | 'rendering' | 'packing') => void, + ) => { + onPhase?.('rendering') + return new Promise<{ blob: Blob; filename: string }>((resolve) => { + resolveExport = resolve + }) + }, + ) + const createObjectURL = vi.fn(() => 'blob:asset-package') + const revokeObjectURL = vi.fn() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL }) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + const button = screen.getByRole('button', { name: '导出游戏资产包' }) + fireEvent.click(button) + fireEvent.click(button) + expect(screen.getByText('正在生成图片')).toBeTruthy() + expect(exporter).toHaveBeenCalledTimes(1) + + resolveExport({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-character-1.zip', + }) + await waitFor(() => expect(screen.getByText('下载完成')).toBeTruthy()) + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:asset-package') + click.mockRestore() + }) + + it('展示具体错误字段,并允许修复后重试', async () => { + const exporter = vi + .fn() + .mockRejectedValueOnce(new Error('actions[0].frames: 缺帧')) + .mockResolvedValueOnce({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Aster-character-1.zip', + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:retry'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '导出游戏资产包' })) + await waitFor(() => expect(screen.getByText('导出失败:actions[0].frames: 缺帧')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: '重新导出' })) + await waitFor(() => expect(screen.getByText('下载完成')).toBeTruthy()) + expect(exporter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/features/export-package/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx new file mode 100644 index 00000000..12f19f51 --- /dev/null +++ b/frontend/src/features/export-package/export-panel.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react' + +import type { ExportPackageModel } from './model' +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportPhase, + type AssetExportResult, +} from './asset-export' + +export type AssetExporter = ( + model: ExportPackageModel, + onPhase?: (phase: AssetExportPhase) => void, +) => Promise + +export interface ExportPanelProps { + model: ExportPackageModel + qualityIssueCount?: number + exporter?: AssetExporter +} + +type ExportState = + | { status: 'idle' } + | { status: 'working'; phase: AssetExportPhase } + | { status: 'success' } + | { status: 'failure'; message: string } + +const PHASE_LABELS: Readonly> = { + validating: '正在检查导出条件', + collecting: '正在整理素材', + rendering: '正在生成图片', + packing: '正在打包', +} + +const defaultExporter: AssetExporter = (model, onPhase) => exportGameAssets(model, { onPhase }) + +export function ExportPanel({ + model, + qualityIssueCount = 0, + exporter = defaultExporter, +}: ExportPanelProps) { + const [state, setState] = useState({ status: 'idle' }) + const plan = createAssetExportPlan(model) + const working = state.status === 'working' + + const startExport = async () => { + if (working) return + setState({ status: 'working', phase: 'validating' }) + try { + const result = await exporter(model, (phase) => setState({ status: 'working', phase })) + const url = URL.createObjectURL(result.blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = result.filename + anchor.click() + URL.revokeObjectURL(url) + setState({ status: 'success' }) + } catch (error) { + setState({ + status: 'failure', + message: error instanceof Error ? error.message : '未知错误', + }) + } + } + + return ( +
+
+

GAME ASSETS

+

资产导出

+

逐帧透明 PNG、Sprite Sheet 与动画 JSON

+
+ +
+
动作方向
+
{plan.length} 组
+
逐帧原图
+
+ {plan.reduce((total, item) => total + item.frames.length, 0)} 张 +
+
每行上限
+
8 帧
+
+ + {qualityIssueCount > 0 ? ( +

+ 当前有 {qualityIssueCount} 项质量问题,全部通过后才能导出 +

+ ) : null} + + {state.status === 'working' ? ( +

+ {PHASE_LABELS[state.phase]} +

+ ) : state.status === 'failure' ? ( +

+ 导出失败:{state.message} +

+ ) : state.status === 'success' ? ( +

下载完成

+ ) : null} + + + {plan.length === 0 ?

没有可导出的已确认动作

: null} +
+ ) +} diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts new file mode 100644 index 00000000..82f2ab36 --- /dev/null +++ b/frontend/src/features/export-package/index.ts @@ -0,0 +1,29 @@ +/** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ +export { ExportPanel } from './export-panel' +export type { + ExportAction, + ExportAnchor, + ExportFrame, + ExportPackageModel, + ExportQualityStatus, + ExportSequence, + ExportSourceReference, +} from './model' +export { + EXPORT_PACKAGE_JSON_SCHEMA_TEXT, + EXPORT_PACKAGE_SCHEMA_VERSION, + validateExportPackageModel, + type GenericExportMetadata, +} from './contract' +export { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +export { + createAssetExportPlan, + exportGameAssets, + type AssetExportTarget, + type AssetExportTargetContext, + type AssetExportTargetFile, + type AssetExportPhase, + type AssetExportResult, + type AssetExportRuntime, + type ExportGameAssetsOptions, +} from './asset-export' diff --git a/frontend/src/features/export-package/model.ts b/frontend/src/features/export-package/model.ts new file mode 100644 index 00000000..d6095c38 --- /dev/null +++ b/frontend/src/features/export-package/model.ts @@ -0,0 +1,64 @@ +import type { ActionType, Frame } from '@/entities' + +/** + * 导出模块只读取这份模型,不直接读取 Playtest 页面状态。 + * 页面或后端适配器负责把当前角色、动作和生成记录整理成该模型。 + */ +export interface ExportFrame { + imageUrl: string + durationMs: number + rootMotion: Frame['rootMotion'] + keyFrame: boolean +} + +/** 通用契约使用左上角为原点、y 轴向下的 0-1 归一化坐标。 */ +export interface ExportAnchor { + x: number + y: number +} + +export type ExportQualityStatus = 'passed' | 'pending' | 'failed' + +export interface ExportSequence { + direction: string + /** 后端声明的完整帧数;不能用 frames.length 代替,否则无法发现缺帧。 */ + expectedFrameCount: number + loop: boolean + anchor: ExportAnchor + /** 脚底线距离画布顶部的像素值。 */ + footY: number + /** 只有 passed 的动作序列可以进入正式导出包。 */ + qualityStatus: ExportQualityStatus + frames: readonly ExportFrame[] +} + +export interface ExportAction { + id: string + name: string + type: ActionType | 'crouch' + fps: number + sequences: readonly ExportSequence[] +} + +export interface ExportSourceReference { + workflowRunId: string + generationIds: readonly string[] +} + +export interface ExportPackageModel { + characterId: string + characterName: string + outfitId: string + outfitName: string + characterTemplateUrl: string | null + baseFrameCount: number + /** 同一导出包内所有帧必须使用相同画布尺寸。 */ + canvas: { + width: number + height: number + } + /** 生成链路引用,用于从导出物追溯到 WorkflowRun 与生成任务。 */ + /** 独立 Playtest 入口可能没有 WorkflowRun/Generation 追溯信息。 */ + source: ExportSourceReference | null + actions: readonly ExportAction[] +} diff --git a/frontend/src/features/publish/index.test.ts b/frontend/src/features/publish/index.test.ts new file mode 100644 index 00000000..dc423bb3 --- /dev/null +++ b/frontend/src/features/publish/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import { buildPlaytestPath } from './index' + +describe('buildPlaytestPath', () => { + it('uses one encoded route contract for every Playtest caller', () => { + expect( + buildPlaytestPath({ + characterId: 'character/1', + outfitId: 'default outfit', + actionId: 'walk left', + }), + ).toBe('/playtest/character%2F1/default%20outfit?actionId=walk+left') + }) +}) diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts new file mode 100644 index 00000000..3f7673a8 --- /dev/null +++ b/frontend/src/features/publish/index.ts @@ -0,0 +1,72 @@ +/** 已经写入 Character 后端记录、可以由资产库和 Playtest 读取的目标。 */ +export interface PublishedAssetTarget { + characterId: string + outfitId: string + actionId?: string +} + +/** 发布后的页面入口。真正的资产写入由 CharacterApis 完成。 */ +export function buildPlaytestPath(target: PublishedAssetTarget): string { + const path = `/playtest/${encodeURIComponent(target.characterId)}/${encodeURIComponent(target.outfitId)}` + return target.actionId ? `${path}?${new URLSearchParams({ actionId: target.actionId })}` : path +} + +const ACTION_NAMES: Record = { + idle: '待机', + walk: '行走', + jump: '跳跃', + attack: '攻击', + custom: '自定义动作', +} + +/** 动作 ID 绑定本次运行,允许同一角色保存多个 custom 或同类型动作。 */ +export function buildPublishedActionId(characterId: string, runId: string): string { + return `${characterId}-${runId}` +} + +/** 审核通过时才把 WorkflowRun 中的完整动画写入正式 Character 资产树。 */ +export async function publishWorkflowRun( + characterApis: CharacterApis, + run: WorkflowRun, +): Promise { + if (!run.characterId || !run.outfitId) throw new Error('工作流还没有关联角色与造型') + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + const step = revision?.steps.find((item) => item.type === 'action-generation') + if (step?.type !== 'action-generation' || step.status !== 'passed' || !step.output) { + throw new Error('动作生成尚未完成,不能发布') + } + const result = step.output + const character = await characterApis.get(run.characterId) + const outfit = character.outfits.find((item) => item.id === run.outfitId) + if (!outfit) throw new Error('角色中没有找到工作流关联的造型') + const actionId = buildPublishedActionId(character.id, run.id) + const action = { + id: actionId, + outfitId: outfit.id, + name: + result.actionType === 'custom' + ? run.prompt?.trim() || '自定义动作' + : ACTION_NAMES[result.actionType], + expectedFrameCount: result.frames.length, + kind: 'custom' as const, + type: result.actionType, + fps: 8, + keyFrameIndex: 0, + frames: result.frames.map((frame) => ({ + imageUrl: frame.url, + durationMs: frame.durationMs, + rootMotion: null, + })), + } + return characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfit.id + ? { ...item, actions: [...item.actions.filter((old) => old.id !== actionId), action] } + : item, + ), + }) +} + +export { canPublishToPlaytest, workflowRunToCharacter } from './workflow-to-character' +import type { ActionType, Character, CharacterApis, WorkflowRun } from '@/entities' diff --git a/frontend/src/features/publish/workflow-to-character.ts b/frontend/src/features/publish/workflow-to-character.ts new file mode 100644 index 00000000..6a8af2fe --- /dev/null +++ b/frontend/src/features/publish/workflow-to-character.ts @@ -0,0 +1,141 @@ +/** + * WorkflowRun → Character 桥接层。 + * 从工作流步骤中提取数据,组装为 Playtest 可消费的 Character 实体。 + * 按 5 步流程读取:character-setup / character-template / template-candidate / + * action-generation / review。 + */ +import type { + Action, + Character, + CharacterTemplateCandidate, + Frame, + Outfit, + WorkflowRevision, + WorkflowRun, + WorkflowStep, +} from '@/entities' + +function getRevision(run: WorkflowRun): WorkflowRevision | null { + return run.revisions.find((r) => r.id === run.currentRevisionId) ?? null +} + +function getStep(revision: WorkflowRevision, type: string): WorkflowStep | null { + return revision.steps.find((s) => s.type === type) ?? null +} + +/** 从 character-template 步骤提取母版 URL */ +function extractCharacterTemplateUrl(revision: WorkflowRevision): string | null { + const step = getStep(revision, 'character-template') + const output = step?.output as { images?: Array<{ url: string }> } | null + if (!output?.images?.length) return null + return output.images[0]?.url ?? null +} + +/** 从 character-template 步骤提取候选列表 */ +function extractCandidates(revision: WorkflowRevision): CharacterTemplateCandidate[] { + const step = getStep(revision, 'character-template') + const output = step?.output as { images?: Array<{ url: string }> } | null + if (!output?.images) return [] + return output.images.map((img, i) => ({ + id: `candidate-${i}`, + imageUrl: img.url, + attemptId: `attempt-${Date.now()}`, + })) +} + +/** 从 character-setup 步骤提取角色描述 */ +function extractDescription(revision: WorkflowRevision): string { + const step = getStep(revision, 'character-setup') + const input = step?.input as { description?: string } | null + return input?.description?.trim() || '未命名角色' +} + +/** 动作生成结果的帧 URL 列表(兼容 first_frame 单帧与 complete_animation 多帧) */ +function extractActionResult(revision: WorkflowRevision) { + const step = getStep(revision, 'action-generation') + return step?.type === 'action-generation' ? step.output : null +} + +/** + * 将 WorkflowRun 转换为 Character 实体。 + * 如果关键步骤未完成,返回 null。 + */ +export function workflowRunToCharacter(run: WorkflowRun): Character | null { + const revision = getRevision(run) + if (!revision) return null + + const templateStep = getStep(revision, 'character-template') + + // 至少需要母版已生成 + if (!templateStep || templateStep.status !== 'passed') return null + + const characterTemplateUrl = extractCharacterTemplateUrl(revision) + const candidates = extractCandidates(revision) + const description = extractDescription(revision) + const actionResult = extractActionResult(revision) + + // 构建帧列表 + const frames: Frame[] = (actionResult?.frames ?? []).map((frame) => ({ + imageUrl: frame.url, + durationMs: frame.durationMs, + rootMotion: null, + })) + + // 构建动作 + const actions: Action[] = [] + if (frames.length > 0) { + actions.push({ + id: `${run.id}-action`, + outfitId: `${run.id}-outfit`, + name: description.length > 8 ? `${description.slice(0, 8)}…` : description || '动作', + expectedFrameCount: frames.length, + kind: 'custom', + type: actionResult?.actionType ?? 'custom', + fps: 8, + keyFrameIndex: null, + frames, + }) + } + + // 构建造型 + const outfit: Outfit = { + id: `${run.id}-outfit`, + characterId: run.id, + name: '默认造型', + candidateCharacterTemplates: candidates, + characterTemplateUrl, + baseFrames: characterTemplateUrl ? [{ imageUrl: characterTemplateUrl }] : [], + actions, + } + + // 构建角色 + const character: Character = { + id: run.id, + projectId: run.projectId, + outfits: [outfit], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + return character +} + +/** + * 检查 WorkflowRun 是否已准备好导出到 Playtest。 + * 至少需要:母版已确认 + 动作生成完成。 + */ +export function canPublishToPlaytest(run: WorkflowRun): boolean { + const revision = getRevision(run) + if (!revision) return false + + const templateStep = getStep(revision, 'character-template') + const actionStep = getStep(revision, 'action-generation') + + const reviewStep = getStep(revision, 'review') + return ( + run.status === 'completed' && + templateStep?.status === 'passed' && + actionStep?.status === 'passed' && + reviewStep?.status === 'passed' + ) +} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts index 3a48b663..3b9d1547 100644 --- a/frontend/src/features/review/index.ts +++ b/frontend/src/features/review/index.ts @@ -1,12 +1,26 @@ -/** - * 逐帧查看已生成的动作,供用户在导出前过一遍。 - * - * 只看不改:服务端不返回质检结论,产品上也不设「打回此帧」, - * 所以这里没有任何写操作,帧数据不会因为查看而改变。 - */ -export interface ReviewProps { - /** 动作 ID 只在造型内唯一,调用方须自行持有所属造型,不能拿它跨角色定位。 */ - actionId: string - frameIndex: number - onSelectFrame(index: number): void +import type { WorkflowRun } from '@/entities' + +/** 工作流审核的用户决定。Playtest 中的逐帧检查不使用这套写操作。 */ +export type ReviewDecision = + | { kind: 'approve' } + | { kind: 'request_changes'; restartStepId: string } + +export interface ReviewSubmission { + runId: WorkflowRun['id'] + decision: ReviewDecision +} + +interface ReviewController { + approveReview(runId: WorkflowRun['id']): WorkflowRun + restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun +} + +/** 把审核决定交给唯一的 WorkflowController 执行,不在 Review Feature 中复制状态机。 */ +export function submitReview( + controller: ReviewController, + { runId, decision }: ReviewSubmission, +): WorkflowRun { + return decision.kind === 'approve' + ? controller.approveReview(runId) + : controller.restart(runId, decision.restartStepId) } diff --git a/frontend/src/features/workflow-controller/action-generation-task.ts b/frontend/src/features/workflow-controller/action-generation-task.ts new file mode 100644 index 00000000..220b2602 --- /dev/null +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -0,0 +1,231 @@ +import type { + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + Generation, + GenerationApis, + GenerationEvent, + WorkflowRun, + WorkflowRunStore, +} from '@/entities' +import { + beginActionGenerationState, + completeActionGenerationState, + getActiveStep, + getCurrentRevision, + recordActionGenerationTaskState, +} from './workflow-state' + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface ActionGenerationTask { + start(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput): Promise + resume(runId: WorkflowRun['id']): Promise + stop(runId: WorkflowRun['id']): void +} + +interface CreateActionGenerationTaskOptions { + store: WorkflowRunStore + generationApis: GenerationApis + createSubmissionId: () => string +} + +/** 管理完整动作生成的提交、订阅和刷新恢复,页面只负责提供业务输入。 */ +export function createActionGenerationTask({ + store, + generationApis, + createSubmissionId, +}: CreateActionGenerationTaskOptions): ActionGenerationTask { + const submissions = new Map>() + const subscriptions = new Map() + + function requireRun(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function currentActionStep(runId: WorkflowRun['id']) { + const run = requireRun(runId) + const revision = getCurrentRevision(run) + const step = getActiveStep(revision) + return { run, revision, step } + } + + function start(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput) { + const { run, revision, step } = currentActionStep(runId) + if (run.status !== 'active' || step?.type !== 'action-generation') return Promise.resolve(run) + if (step.taskId) { + subscribe(run, step.taskId) + return Promise.resolve(run) + } + if (step.submissionId) throw new Error('动作生成请求仍在等待后端确认,不能重复提交') + + const key = `${runId}:${revision.id}:${step.id}` + const pending = submissions.get(key) + if (pending) return pending + const submission = submit(runId, input).finally(() => submissions.delete(key)) + submissions.set(key, submission) + return submission + } + + async function submit(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput) { + const submissionId = createSubmissionId() + save(beginActionGenerationState(requireRun(runId), input, submissionId)) + try { + const generation = await generationApis.create(input) + const latest = requireRun(runId) + const revision = getCurrentRevision(latest) + const step = getActiveStep(revision) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + step?.type !== 'action-generation' || + step.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'complete_animation') { + throw new Error('生成任务类型与动作生成步骤不匹配') + } + const withTask = save(recordActionGenerationTaskState(latest, generation.id, input)) + if (latest.status === 'interrupted') return withTask + if (generation.status === 'pending' || generation.status === 'running') { + subscribe(withTask, generation.id) + return withTask + } + return applyTerminal(runId, generation.id, generation) + } catch (cause) { + const latest = store.get(runId) + if (latest?.status === 'active') { + const step = getActiveStep(getCurrentRevision(latest)) + if (step?.type === 'action-generation') { + save(completeActionGenerationState(latest, { error: message(cause, '动作生成请求失败') })) + } + } + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function subscribe(run: WorkflowRun, taskId: string) { + const key = `${run.id}:${taskId}` + if (subscriptions.has(key)) return + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + if (event.taskId !== taskId || event.status === 'pending' || event.status === 'running') + return + applyTerminal(run.id, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function applyTerminal( + runId: WorkflowRun['id'], + taskId: string, + task: Generation | GenerationEvent, + ) { + const latest = requireRun(runId) + if (latest.status !== 'active') return latest + const step = getActiveStep(getCurrentRevision(latest)) + if (step?.type !== 'action-generation' || step.taskId !== taskId) return latest + stopSubscription(runId, taskId) + if (task.status === 'failed') { + return save( + completeActionGenerationState(latest, { + error: task.error?.trim() || '动作生成任务失败', + }), + ) + } + const result = task.result + if ( + task.type !== 'complete_animation' || + result?.type !== 'complete_animation' || + result.frames.length === 0 + ) { + return save( + completeActionGenerationState(latest, { error: '动作生成完成但未返回有效动画帧' }), + ) + } + return save(completeActionGenerationState(latest, result as CompleteAnimationGenerationResult)) + } + + async function resume(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run || run.status !== 'active') return run + const step = getActiveStep(getCurrentRevision(run)) + if (step?.type !== 'action-generation') return run + if (step.submissionId && !step.taskId) { + return save( + completeActionGenerationState(run, { + error: '页面刷新时动作生成请求尚未返回任务 ID,请重新开始该步骤', + }), + ) + } + if (!step.taskId) { + if (step.input) return start(runId, step.input) + return save( + completeActionGenerationState(run, { + error: '动作生成尚未完成提交,请重新确认角色候选', + }), + ) + } + try { + const task = await generationApis.get(run.projectId, step.taskId) + if (task.status === 'pending' || task.status === 'running') { + subscribe(run, step.taskId) + return store.get(runId) + } + return applyTerminal(runId, step.taskId, task) + } catch (cause) { + const latest = store.get(runId) + if (!latest || latest.status !== 'active') return latest + return save( + completeActionGenerationState(latest, { + error: message(cause, '恢复动作生成任务失败'), + }), + ) + } + } + + function stopSubscription(runId: string, taskId: string) { + const key = `${runId}:${taskId}` + const active = subscriptions.get(key) + subscriptions.delete(key) + try { + active?.stop() + } catch { + // 停止订阅失败不能破坏已经保存的工作流状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, active] of subscriptions) { + if (active.runId !== runId) continue + subscriptions.delete(key) + try { + active.stop() + } catch { + // 同上。 + } + } + } + + return { start, resume, stop } +} + +function message(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} diff --git a/frontend/src/features/workflow-controller/character-template-task.ts b/frontend/src/features/workflow-controller/character-template-task.ts new file mode 100644 index 00000000..80002d70 --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,464 @@ +import { + parseCharacterTemplateGenerationResult, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, +} from '@/entities' +import { + getActiveStep, + getCurrentRevision, + replaceWorkflowStep, + type WorkflowStepTarget, +} from './workflow-state' + +interface ApplyServerResultInput extends WorkflowStepTarget { + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface CharacterTemplateTask { + /** 启动或继续目标角色图步骤;同一实例内的重复调用共享一次提交。 */ + start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 停止指定运行记录的前端任务订阅,不改变 WorkflowRun 状态。 */ + stop(runId: WorkflowRun['id']): void +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore + generationApis: GenerationApis + createSubmissionId: () => string +} + +/** + * 角色图异步任务的生命周期。 + * + * 它只处理当前角色图步骤与后端 Generation 的关联,不决定整个工作流下一步走什么。 + * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 + */ +export function createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>() + const subscriptions = new Map() + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise { + const run = requireWorkflow(runId) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + revision.id !== target.revisionId || + !step || + step.type !== 'character-template' || + step.status !== 'active' + ) { + return Promise.resolve(run) + } + if (step.taskId) { + ensureTaskSubscription(run, target.revisionId, target.stepId, step.taskId) + return Promise.resolve(requireWorkflow(runId)) + } + if (!step.input) throw new Error('角色图生成步骤缺少输入快照') + return submit(runId, target) + } + + function submit(runId: WorkflowRun['id'], target: WorkflowStepTarget) { + const key = submissionKey(runId, target.revisionId, target.stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performSubmission(runId, target).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performSubmission( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === target.stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== target.revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, target.revisionId, target.stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createSubmissionId() + const submitting = replaceWorkflowStep(before, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, submissionId } + }) + save(submitting) + + try { + const generation = await generationApis.create(beforeStep.input) + const latest = requireWorkflow(runId) + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === target.stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== target.revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + const typeOk = + generation.type === 'character_template' || generation.type === 'character_image' + // project_id 有一方为 null/undefined 时容忍(后端可能未返回);双方都有值时必须一致 + const projectOk = + generation.projectId == null || + latest.projectId == null || + String(generation.projectId) === String(latest.projectId) + if (!typeOk || !projectOk) { + throw new Error( + `生成任务返回的类型或项目与当前 WorkflowRun 不匹配 ` + + `(type: ${generation.type}, project: ${generation.projectId} vs ${latest.projectId})`, + ) + } + + const withTask = replaceWorkflowStep(latest, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, taskId: generation.id, submissionId: null } + }) + save(withTask) + + if (latest.status === 'interrupted') return withTask + if (generation.status === 'failed') { + return markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, target.revisionId, target.stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markFailed(runId, target, null, submissionId, errorMessage(cause, '角色图生成请求失败')) + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + ) { + const key = subscriptionKey(run.id, revisionId, stepId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + handleGenerationEvent(run.id, { revisionId, stepId }, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function handleGenerationEvent( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + taskId: string, + event: GenerationEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markFailed(runId, target, taskId, null, event.error?.trim() || '角色图生成任务失败') + return + } + if (event.type !== 'character_template') { + markFailed(runId, target, taskId, null, '任务结果类型与角色图生成步骤不匹配') + return + } + applyServerResult(runId, { + ...target, + taskId, + result: event.result, + }) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = getWorkflow(runId) + if (!run || run.status !== 'active') return run + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { + return run + } + const target = { revisionId: revision.id, stepId: activeStep.id } + + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markFailed( + run.id, + target, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await generationApis.get(run.projectId, activeStep.taskId) + const latest = getWorkflow(run.id) + if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { + return latest + } + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) + if ( + latestStep?.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId !== activeStep.taskId + ) { + return latest + } + if (task.id !== latestStep.taskId) { + throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') + } + if (task.type !== 'character_template') { + return markFailed( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleGenerationEvent( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + taskEvent(task), + ) + } + } + return getWorkflow(runId) + } + + function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { + return run + } + + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === input.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + step.taskId !== input.taskId + ) { + return run + } + + const result = parseCharacterTemplateGenerationResult(input.result) + if (!result) { + return markFailed( + runId, + { revisionId: revision.id, stepId: step.id }, + input.taskId, + null, + '角色图生成任务返回了无法识别的结果', + ) + } + const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') + if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') + + const updated: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((current) => { + if (current.id === step.id && current.type === 'character-template') { + return { + ...current, + status: 'passed' as const, + output: result, + taskId: null, + submissionId: null, + } + } + if (current.id === candidateStep.id && current.type === 'template-candidate') { + return { ...current, status: 'active' as const } + } + return current + }), + } + }), + } + stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) + return save(updated) + } + + function markFailed( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== target.revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + (expectedTaskId !== null && step.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) + ) { + return run + } + + const failureMessage = error.trim() || '角色图生成失败' + const failed: WorkflowRun = { + ...replaceWorkflowStep( + run, + target.revisionId, + target.stepId, + (current) => ({ + ...current, + status: 'failed', + taskId: null, + submissionId: null, + error: failureMessage, + }), + (current) => ({ + ...current, + status: 'failed', + generationStatus: 'failed', + }), + ), + status: 'failed', + } + if (step.taskId) { + stopSubscription(subscriptionKey(run.id, revision.id, step.id, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + } + + return { start, resume, stop } +} + +function taskEvent(task: Generation): GenerationEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function subscriptionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, +) { + return `${runId}:${revisionId}:${stepId}:${taskId}` +} + +function submissionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], +) { + return `${runId}:${revisionId}:${stepId}` +} diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 00000000..89dd4887 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,696 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + WORKFLOW_STEP_ORDER, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from '.' + +const NOW = '2026-07-30T12:00:00.000Z' + +type RunListener = (run: WorkflowRun) => void + +function cloneRun(run: WorkflowRun): WorkflowRun { + return structuredClone(run) +} + +function createMemoryStore() { + const runs = new Map() + const listeners = new Map>() + const listListeners = new Set<(runs: WorkflowRun[]) => void>() + + const get = vi.fn((runId: string): WorkflowRun | null => { + const run = runs.get(runId) + return run ? cloneRun(run) : null + }) + + const save = vi.fn((run: WorkflowRun): void => { + const snapshot = cloneRun(run) + runs.set(run.id, snapshot) + + for (const listener of listeners.get(run.id) ?? []) { + listener(cloneRun(snapshot)) + } + for (const listener of listListeners) listener([...runs.values()].map(cloneRun)) + }) + + const subscribe = vi.fn((runId: string, listener: RunListener): (() => void) => { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + } + }) + + const list = vi.fn(() => [...runs.values()].map(cloneRun)) + const subscribeAll = vi.fn((listener: (runs: WorkflowRun[]) => void) => { + listListeners.add(listener) + return () => listListeners.delete(listener) + }) + + return { get, list, save, subscribe, subscribeAll } +} + +function createIdFactory() { + let nextId = 0 + return vi.fn(() => `id-${++nextId}`) +} + +function deferNextGeneration(harness: ReturnType) { + let resolve!: (generation: Generation<'character_template'>) => void + const promise = new Promise>((resolvePromise) => { + resolve = resolvePromise + }) + vi.mocked(harness.generationApis.create).mockImplementationOnce( + async () => (await promise) as Generation, + ) + return { resolve } +} + +function pendingCharacterTemplateGeneration(): Generation<'character_template'> { + return { + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + } +} + +function createHarness() { + const store = createMemoryStore() + const taskListeners = new Map void>() + + const createGeneration: GenerationApis['create'] = async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const subscribeTask = vi.fn( + (projectId: string, taskId: string, onEvent: (event: GenerationEvent) => void) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }, + ) + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error('GenerationApis.get is not used until a run is resumed') + }), + subscribe: subscribeTask, + } + + const controller = createWorkflowController({ + store, + generationApis, + createId: createIdFactory(), + now: () => NOW, + }) + + return { + controller, + generationApis, + subscribeTask, + store, + getTaskListener(projectId: string, taskId: string) { + return taskListeners.get(`${projectId}:${taskId}`) ?? null + }, + emitTask(projectId: string, taskId: string, event: GenerationEvent) { + const listener = taskListeners.get(`${projectId}:${taskId}`) + expect(listener, `missing task subscription for ${projectId}:${taskId}`).toBeTypeOf( + 'function', + ) + listener?.(event) + }, + } +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find(({ id }) => id === run.currentRevisionId) + if (!revision) { + throw new Error(`Current revision ${run.currentRevisionId} is missing`) + } + return revision +} + +function step(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + const workflowStep = currentRevision(run).steps.find((item) => item.type === type) + if (!workflowStep) { + throw new Error(`Workflow step ${type} is missing`) + } + return workflowStep +} + +async function createAiRun(harness: ReturnType) { + return harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }) +} + +const SPRITE_SIZE = { width: 64, height: 64 } + +async function startCharacterTemplate(harness: ReturnType) { + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + return run +} + +describe('createWorkflowController', () => { + it('creates one revision with the fixed five steps and seeds AI input from the prompt', async () => { + const harness = createHarness() + + expect(harness.controller).toEqual( + expect.objectContaining({ + create: expect.any(Function), + getWorkflow: expect.any(Function), + subscribe: expect.any(Function), + updateCharacterSetup: expect.any(Function), + nextStep: expect.any(Function), + restart: expect.any(Function), + resume: expect.any(Function), + interrupt: expect.any(Function), + }), + ) + + const run = await createAiRun(harness) + const revision = currentRevision(run) + + expect(run.prompt).toBe('pixel knight') + expect(run.projectId).toBe('project-1') + expect(run.revisions).toHaveLength(1) + expect(run.currentRevisionId).toBe(revision.id) + expect(revision.basedOnRevisionId).toBeNull() + expect(revision.restartStepId).toBeNull() + expect(revision.createdAt).toBe(NOW) + expect(revision.steps.map(({ type }) => type)).toEqual(WORKFLOW_STEP_ORDER) + expect(revision.steps.map(({ status }) => status)).toEqual([ + 'active', + 'locked', + 'locked', + 'locked', + 'locked', + ]) + expect(step(run, 'character-setup').input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + + const allIds = [run.id, revision.id, ...revision.steps.map(({ id }) => id)] + expect(new Set(allIds).size).toBe(allIds.length) + expect(harness.store.save).toHaveBeenCalledWith(run) + }) + + it('persists the task id before subscribing and never submits the active generation twice', async () => { + const harness = createHarness() + + await startCharacterTemplate(harness) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(harness.generationApis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: 'project-1', + prompt: 'pixel knight', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }) + expect(harness.subscribeTask).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + + const taskSaveIndex = harness.store.save.mock.calls.findIndex(([savedRun]) => { + return step(savedRun, 'character-template').taskId === 'task-1' + }) + expect(taskSaveIndex).toBeGreaterThanOrEqual(0) + expect(harness.store.save.mock.invocationCallOrder[taskSaveIndex]).toBeLessThan( + harness.subscribeTask.mock.invocationCallOrder[0], + ) + + const createdRun = harness.store.save.mock.calls[0]?.[0] + if (!createdRun) throw new Error('Expected the created WorkflowRun to be saved') + const activeRun = harness.controller.getWorkflow(createdRun.id) + if (!activeRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(activeRun, 'character-setup').status).toBe('passed') + expect(step(activeRun, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + }) + + await harness.controller.nextStep(activeRun.id) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('shares one submission when nextStep is called concurrently', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const first = harness.controller.nextStep(run.id, SPRITE_SIZE) + const second = harness.controller.nextStep(run.id, SPRITE_SIZE) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + deferred.resolve(pendingCharacterTemplateGeneration()) + await Promise.all([first, second]) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('keeps an active submission alive when resume uses the same controller', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) + const resumed = await harness.controller.resume(run.id) + + expect(resumed?.status).toBe('active') + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'active', + taskId: null, + submissionId: expect.any(String), + }) + + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('handles a terminal snapshot emitted synchronously when subscribing', async () => { + const harness = createHarness() + const stop = vi.fn() + harness.subscribeTask.mockImplementationOnce((_projectId, _taskId, onEvent) => { + onEvent({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/synchronous.png' }], + }, + }) + return stop + }) + + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(harness.controller.getWorkflow(run.id)!, 'template-candidate').status).toBe( + 'active', + ) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ignores another task result and advances only when the matching task completes', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'another-task', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/wrong.png' }], + }, + }) + + const unchangedRun = harness.controller.getWorkflow(run.id) + if (!unchangedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(unchangedRun, 'character-template')).toMatchObject({ + status: 'active', + output: null, + taskId: 'task-1', + }) + expect(step(unchangedRun, 'template-candidate').status).toBe('locked') + + const result = { + type: 'character_template' as const, + images: [{ url: 'https://example.com/knight.png' }], + } + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result, + }) + + const completedRun = harness.controller.getWorkflow(run.id) + if (!completedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(completedRun, 'character-template')).toMatchObject({ + status: 'passed', + output: result, + taskId: null, + }) + expect(step(completedRun, 'template-candidate').status).toBe('active') + }) + + it('marks the step, revision, generation, and run as failed when the task fails', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'model unavailable', + result: null, + }) + + const failedRun = harness.controller.getWorkflow(run.id) + if (!failedRun) throw new Error('Expected the WorkflowRun to remain available') + const revision = currentRevision(failedRun) + expect(step(failedRun, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: 'model unavailable', + }) + expect(revision.status).toBe('failed') + expect(revision.generationStatus).toBe('failed') + expect(failedRun.status).toBe('failed') + }) + + it('resumes a persisted in-flight task without creating another generation', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(resumed?.id).toBe(run.id) + expect(harness.generationApis.get).toHaveBeenCalledWith('project-1', 'task-1') + expect(resumeSubscribe).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('applies a completed task found during refresh before subscribing again', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/recovered.png' }], + }, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(resumed!, 'template-candidate').status).toBe('active') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe after interrupting while resume waits for the task query', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + let resolveTask!: (task: Generation) => void + const pendingTask = new Promise((resolve) => { + resolveTask = resolve + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { + ...harness.generationApis, + get: vi.fn(() => pendingTask), + subscribe: resumeSubscribe, + }, + }) + + const resuming = resumedController.resume(run.id) + resumedController.interrupt(run.id) + resolveTask({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumed = await resuming + + expect(resumed?.status).toBe('interrupted') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('fails safely after refresh when the request was sent before taskId arrived', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const uncertainSnapshot = harness.store.save.mock.calls + .map(([savedRun]) => savedRun) + .find((savedRun) => { + const template = step(savedRun, 'character-template') + return template.submissionId !== null && template.taskId === null + }) + if (!uncertainSnapshot) throw new Error('expected the submitting snapshot to be persisted') + harness.store.save(uncertainSnapshot) + vi.mocked(harness.generationApis.create).mockClear() + + const restoredController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + }) + + const restored = await restoredController.resume(run.id) + + expect(restored?.status).toBe('failed') + expect(step(restored!, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + }) + expect(harness.generationApis.create).not.toHaveBeenCalled() + }) + + it('records a task id that returns after the run was interrupted', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) + await harness.controller.interrupt(run.id) + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + const interrupted = harness.controller.getWorkflow(run.id) + expect(interrupted?.status).toBe('interrupted') + expect(step(interrupted!, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + submissionId: null, + }) + expect(harness.subscribeTask).not.toHaveBeenCalled() + }) + + it('keeps an interrupted run interrupted when a queued failure arrives late', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const queuedListener = harness.getTaskListener('project-1', 'task-1') + if (!queuedListener) throw new Error('expected an active task subscription') + + await harness.controller.interrupt(run.id) + queuedListener({ + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'late failure', + result: null, + }) + + expect(harness.controller.getWorkflow(run.id)?.status).toBe('interrupted') + }) + + it('publishes saved updates and can interrupt the current run', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const listener = vi.fn() + const unsubscribe = harness.controller.subscribe(run.id, listener) + + const updated = await harness.controller.updateCharacterSetup(run.id, { + description: 'revised knight', + referenceMedia: [], + }) + expect(step(updated, 'character-setup').input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + + const interrupted = await harness.controller.interrupt(run.id) + + expect(interrupted.status).toBe('interrupted') + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ id: run.id, status: 'interrupted' }), + ) + + unsubscribe() + }) + + it('completes the active action-generation step without throwing and activates review', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + const confirmed = harness.controller.confirmCandidate( + run.id, + 'https://example.com/candidate.png', + ) + + expect(step(confirmed, 'template-candidate')).toMatchObject({ + status: 'passed', + output: { selectedImageUrl: 'https://example.com/candidate.png' }, + }) + expect(step(confirmed, 'action-generation').status).toBe('active') + + const result = { + type: 'complete_animation' as const, + actionType: 'idle' as const, + frames: [{ url: 'https://example.com/frame.png', durationMs: 125 }], + } + const completed = harness.controller.completeActionGeneration(run.id, result) + + expect(step(completed, 'action-generation')).toMatchObject({ + status: 'passed', + output: result, + error: null, + }) + // 动作完成后 review 进入 active,保证刷新后 run 仍满足“恰好一个 active 步骤”的存储校验 + expect(step(completed, 'review').status).toBe('active') + }) + + it('marks the action-generation step failed when the result carries an error', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + harness.controller.confirmCandidate(run.id, 'https://example.com/candidate.png') + + const failed = harness.controller.completeActionGeneration(run.id, { + error: '动作生成完成但未返回有效帧图片', + }) + + expect(step(failed, 'action-generation')).toMatchObject({ + status: 'failed', + error: '动作生成完成但未返回有效帧图片', + }) + expect(step(failed, 'review').status).toBe('locked') + expect(failed.status).toBe('failed') + }) + + it('restarts from a passed stage as a new local revision', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + const advanced = harness.controller.getWorkflow(run.id) + if (!advanced) throw new Error('Expected the workflow to remain available') + const original = currentRevision(advanced) + const restarted = harness.controller.restart(advanced.id, `${original.id}:character-setup`) + + const revision = currentRevision(restarted) + expect(restarted.status).toBe('active') + expect(restarted.revisions).toHaveLength(2) + expect(restarted.revisions[0]?.status).toBe('abandoned') + expect(revision).toMatchObject({ + id: 'id-4', + basedOnRevisionId: original.id, + restartStepId: `${original.id}:character-setup`, + createdAt: NOW, + }) + expect(step(restarted, 'character-setup')).toMatchObject({ + id: 'id-4:character-setup', + status: 'active', + referenceStepIds: [`${original.id}:character-setup`], + }) + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 00000000..b00c79d1 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,310 @@ +import type { + CharacterSetupStepInput, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + GenerationApis, + MediaReference, + WorkflowRun, + WorkflowRunStore, +} from '@/entities' +import { createActionGenerationTask } from './action-generation-task' +import { createCharacterTemplateTask } from './character-template-task' +import { + advanceCharacterSetupState, + acceptUploadedCharacterTemplateState, + approveReviewState, + completeActionGenerationState, + confirmCandidateState, + createWorkflowRunState, + getActiveStep, + getCurrentRevision, + interruptWorkflowRunState, + recordActionGenerationTaskState, + restartWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from './workflow-state' + +/** 创建角色与给已有角色增加动作共用同一条运行状态机。 */ +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput + +export interface WorkflowController { + /** 创建并保存一条纯前端运行记录。 */ + create(input: CreateWorkflowControllerInput): WorkflowRun + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + + /** 订阅指定运行记录的本地变化。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + + /** 修改当前角色资料步骤,页面无需知道步骤内部 ID。 */ + updateCharacterSetup(runId: WorkflowRun['id'], input: CharacterSetupStepInput): WorkflowRun + + /** 采用已上传的角色母版,跳过图片生成与候选选择并激活动作生成。 */ + acceptUploadedCharacterTemplate( + runId: WorkflowRun['id'], + templateUrl: MediaReference, + ): WorkflowRun + + /** + * 推进一个步骤。当前纵切只实现角色资料到角色图生成; + * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + * spriteSize 为项目精灵图尺寸,角色图生成步骤需要传给后端做尺寸校验。 + */ + nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun + + /** 确认候选选择,推进到下一个步骤。 */ + confirmCandidate(runId: WorkflowRun['id'], selectedImageUrl: string): WorkflowRun + + /** 动作生成完成后写回结果,标记 action-generation 为 passed。 */ + completeActionGeneration( + runId: WorkflowRun['id'], + result: CompleteAnimationGenerationResult | { error: string }, + ): WorkflowRun + + /** 提交完整动作生成,并由 Controller 统一处理订阅和刷新恢复。 */ + startActionGeneration( + runId: WorkflowRun['id'], + input: CompleteAnimationGenerationInput, + ): Promise + + /** 审核通过后完成当前版本和整条运行;不在这里执行发布或下载。 */ + approveReview(runId: WorkflowRun['id']): WorkflowRun + + /** 动作生成任务提交后把任务 ID 落盘,供页面刷新后 resume 恢复轮询。 */ + recordActionGenerationTask(runId: WorkflowRun['id'], taskId: string): WorkflowRun + + /** 记录动作生成关联的角色与造型 ID,供导出到 Playtest 使用(刷新后可恢复)。 */ + recordCharacterRefs( + runId: WorkflowRun['id'], + refs: { characterId: string; outfitId: string }, + ): WorkflowRun + + /** 从当前执行线中一个已通过的节点创建新的本地 Revision。 */ + restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore + generationApis: GenerationApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +/** + * Quick Start 与手动工作流共用的流程协调器。 + * + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + const characterTemplateTask = createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId: () => createId('submission'), + }) + const actionGenerationTask = createActionGenerationTask({ + store, + generationApis, + createSubmissionId: () => createId('submission'), + }) + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function create(input: CreateWorkflowControllerInput): WorkflowRun { + return save( + createWorkflowRunState(input, { + runId: createId('run'), + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function updateCharacterSetup( + runId: WorkflowRun['id'], + input: CharacterSetupStepInput, + ): WorkflowRun { + return save(updateCharacterSetupState(requireWorkflow(runId), input)) + } + + function acceptUploadedCharacterTemplate( + runId: WorkflowRun['id'], + templateUrl: MediaReference, + ): WorkflowRun { + return save(acceptUploadedCharacterTemplateState(requireWorkflow(runId), templateUrl)) + } + + async function nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise { + const run = requireActiveWorkflow(requireWorkflow(runId)) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + + if (activeStep.type === 'character-template') { + return characterTemplateTask.start(runId, { + revisionId: revision.id, + stepId: activeStep.id, + }) + } + if (activeStep.type !== 'character-setup') { + throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) + } + + if (!spriteSize) throw new Error('推进角色资料步骤需要项目精灵图尺寸') + + const transitioned = advanceCharacterSetupState(run, spriteSize) + save(transitioned.run) + return characterTemplateTask.start(runId, transitioned.target) + } + + function resume(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run || run.status !== 'active') return Promise.resolve(run) + const step = getActiveStep(getCurrentRevision(run)) + return step?.type === 'action-generation' + ? actionGenerationTask.resume(runId) + : characterTemplateTask.resume(runId) + } + + function interrupt(runId: WorkflowRun['id']): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') return run + + characterTemplateTask.stop(runId) + actionGenerationTask.stop(runId) + const latest = requireWorkflow(runId) + if (latest.status !== 'active') return latest + return save(interruptWorkflowRunState(latest)) + } + + function confirmCandidate(runId: WorkflowRun['id'], selectedImageUrl: string): WorkflowRun { + return save(confirmCandidateState(requireWorkflow(runId), selectedImageUrl)) + } + + function completeActionGeneration( + runId: WorkflowRun['id'], + result: CompleteAnimationGenerationResult | { error: string }, + ): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[completeActionGen] run not active:', run.status) + return run + } + const revision = getCurrentRevision(run) + const step = revision.steps.find((s) => s.type === 'action-generation') + if (!step || step.status !== 'active') { + console.warn('[completeActionGen] step not active:', step?.type, step?.status) + return run + } + return save(completeActionGenerationState(run, result)) + } + + function startActionGeneration( + runId: WorkflowRun['id'], + input: CompleteAnimationGenerationInput, + ) { + return actionGenerationTask.start(runId, input) + } + + function approveReview(runId: WorkflowRun['id']): WorkflowRun { + return save(approveReviewState(requireWorkflow(runId))) + } + + function recordActionGenerationTask(runId: WorkflowRun['id'], taskId: string): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[recordActionTask] run not active:', run.status) + return run + } + return save(recordActionGenerationTaskState(run, taskId)) + } + + function recordCharacterRefs( + runId: WorkflowRun['id'], + refs: { characterId: string; outfitId: string }, + ): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[recordCharacterRefs] run not active:', run.status) + return run + } + return save({ ...run, characterId: refs.characterId, outfitId: refs.outfitId }) + } + + function restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun { + characterTemplateTask.stop(runId) + actionGenerationTask.stop(runId) + return save( + restartWorkflowRunState(requireWorkflow(runId), stepId, { + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + return { + create, + getWorkflow, + subscribe, + updateCharacterSetup, + acceptUploadedCharacterTemplate, + nextStep, + confirmCandidate, + completeActionGeneration, + startActionGeneration, + approveReview, + recordActionGenerationTask, + recordCharacterRefs, + restart, + resume, + interrupt, + } +} + +function createRuntimeId(scope: 'run' | 'revision' | 'submission') { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + return `${scope}-${suffix}` +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce8792..fcb6978b 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,6 @@ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowStep, -} from '@/entities' - -/** 更新当前 Revision 中某个步骤的业务数据。 */ -export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] - data: unknown -} - -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] -} - -/** 把某次服务端调用的结果写回目标步骤。 */ -export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - result: unknown -} - -/** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 - */ -export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ - create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程快照。 */ - getWorkflow(): WorkflowRun - - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise - - /** 连续推进到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise - - /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise - - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ - interrupt(): Promise -} +export { createWorkflowController } from './controller' +export type { + CreateWorkflowControllerInput, + CreateWorkflowControllerOptions, + WorkflowController, +} from './controller' diff --git a/frontend/src/features/workflow-controller/store-invariants.test.ts b/frontend/src/features/workflow-controller/store-invariants.test.ts new file mode 100644 index 00000000..3a5ec503 --- /dev/null +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -0,0 +1,239 @@ +/** + * 状态机存储不变量穷举测试。 + * + * 每个状态转换点之后,run 必须满足 createWorkflowRunStore 的持久化校验 + * (刷新页面后能从 localStorage 恢复)。曾因 completeActionGeneration 后 + * review 未激活导致 active 步骤数为 0,刷新后 run 被校验过滤直接丢失。 + */ +import { describe, expect, it, vi } from 'vitest' + +import type { + Generation, + GenerationApis, + GenerationEvent, + GenerationInput, + WorkflowRun, +} from '@/entities' +import { createWorkflowRunStore } from '@/entities/workflow-run/store' +import { createWorkflowController } from '.' + +/** 内存版 localStorage:save 后重建 store 即模拟刷新恢复。 */ +function createRefreshableStore() { + let snapshot: string | null = null + const storage = { + getItem: (key: string) => (key === 'windup.workflow-runs' ? snapshot : null), + setItem: (_key: string, value: string) => { + snapshot = value + }, + } + const store = createWorkflowRunStore({ storage }) + return { + store, + /** 模拟刷新:用同一份 storage 快照重建 store。 */ + refresh(): typeof store { + return createWorkflowRunStore({ storage }) + }, + } +} + +function createHarness() { + const { store, refresh } = createRefreshableStore() + const taskListeners = new Map void>() + + const generationApis: GenerationApis = { + create: vi.fn( + async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation, + ), + get: vi.fn(async () => { + throw new Error('not used') + }), + subscribe: vi.fn( + (_projectId: string, taskId: string, onEvent: (e: GenerationEvent) => void) => { + taskListeners.set(taskId, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(taskId) + } + }, + ), + } + let idCounter = 0 + const controller = createWorkflowController({ + store, + generationApis, + createId: (scope) => `id-${scope}-${++idCounter}`, + now: () => '2026-07-31T12:00:00.000Z', + }) + + return { + store, + refresh, + taskListeners, + controller, + completeTemplateTask(taskId: string) { + const listener = taskListeners.get(taskId) + if (!listener) throw new Error(`missing listener ${taskId}`) + listener({ + taskId, + type: 'character_template', + status: 'completed', + error: null, + result: { type: 'character_template', images: [{ url: 'https://example.com/c.png' }] }, + }) + }, + } +} + +/** 断言 run 在刷新后仍可恢复(即通过 store 持久化校验)。 */ +function expectRefreshable( + harness: ReturnType, + runId: string, + label: string, +): WorkflowRun { + const restored = harness.refresh().get(runId) + expect(restored, `${label} 刷新后应可恢复`).not.toBeNull() + return restored! +} + +describe('store invariants across every state transition', () => { + it('an add_action run survives refresh before generation starts', () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '挥手', + characterId: 'character-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: [], + }) + + const restored = expectRefreshable(harness, created.id, '增加动作运行创建后') + expect(restored.characterId).toBe('character-1') + expect(restored.outfitId).toBe('outfit-1') + expect( + restored.revisions[0]?.steps.find((step) => step.type === 'action-generation')?.status, + ).toBe('active') + }) + + it('every step of the happy path survives a refresh', async () => { + const harness = createHarness() + + // 1. 创建(character-setup active) + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + const r1 = expectRefreshable(harness, created.id, '创建后') + expect(r1.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 2. 提交角色图任务(character-template active + submissionId) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + const r2 = expectRefreshable(harness, created.id, '角色图任务提交后') + const templateStep2 = r2.revisions[0]!.steps.find((s) => s.type === 'character-template')! + expect(templateStep2.status).toBe('active') + expect(r2.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 3. 角色图完成(character-template passed → template-candidate active) + harness.completeTemplateTask('task-1') + const r3 = expectRefreshable(harness, created.id, '角色图完成后') + expect(r3.revisions[0]!.steps.find((s) => s.type === 'template-candidate')!.status).toBe( + 'active', + ) + + // 4. 确认候选(action-generation active) + harness.controller.confirmCandidate(created.id, 'https://example.com/c.png') + const r4 = expectRefreshable(harness, created.id, '确认候选后') + expect(r4.revisions[0]!.steps.find((s) => s.type === 'action-generation')!.status).toBe( + 'active', + ) + expect(r4.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 5. 动作生成完成(action-generation passed → review active) + harness.controller.recordActionGenerationTask(created.id, 'task-action-1') + harness.controller.completeActionGeneration(created.id, { + type: 'complete_animation', + actionType: 'idle', + frames: [{ url: 'https://example.com/f.png', durationMs: 125 }], + }) + const r5 = expectRefreshable(harness, created.id, '动作完成后') + const reviewStep = r5.revisions[0]!.steps.find((s) => s.type === 'review')! + expect(reviewStep.status).toBe('active') + expect(r5.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) + + it('a failed action generation survives a refresh and stays failed', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + harness.completeTemplateTask('task-1') + harness.controller.confirmCandidate(created.id, 'https://example.com/c.png') + + harness.controller.completeActionGeneration(created.id, { error: '生成服务超时' }) + + const r = expectRefreshable(harness, created.id, '动作失败后') + expect(r.status).toBe('failed') + expect(r.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(0) + expect(r.revisions[0]!.steps.find((s) => s.type === 'action-generation')!.status).toBe('failed') + }) + + it('an interrupted run survives a refresh with exactly one active step', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + harness.controller.interrupt(created.id) + + const r = expectRefreshable(harness, created.id, '中断后') + expect(r.status).toBe('interrupted') + expect(r.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) + + it('a restart from a passed step survives a refresh', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + harness.completeTemplateTask('task-1') + const after = harness.controller.getWorkflow(created.id)! + const revision = after.revisions[0]! + const setupStep = revision.steps.find((s) => s.type === 'character-setup')! + + harness.controller.restart(created.id, setupStep.id) + + const r = expectRefreshable(harness, created.id, '重开后') + expect(r.revisions).toHaveLength(2) + expect(r.revisions[0]!.status).toBe('abandoned') + expect(r.revisions[1]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts new file mode 100644 index 00000000..62d8f414 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, +} from '@/entities' +import { createWorkflowController } from '.' + +describe('WorkflowRun first vertical slice', () => { + it('runs character setup through a completed character-template task', async () => { + const store = createWorkflowRunStore({ storage: null }) + const taskChannel: { listener?: (event: GenerationEvent) => void } = {} + + const createGeneration: GenerationApis['create'] = async ( + input: T, + ) => + ({ + id: 'task-character-template-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error('not used in this slice') + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + taskChannel.listener = onEvent + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + delete taskChannel.listener + } + }), + } + const ids = ['run-1', 'revision-1'] + const controller = createWorkflowController({ + store, + generationApis, + createId: () => ids.shift() ?? 'unexpected-id', + now: () => '2026-07-30T12:00:00.000Z', + }) + + const created = await controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + await controller.nextStep(created.id, { width: 64, height: 64 }) + + const inFlight = store.get(created.id) + expect( + inFlight?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'active', + taskId: 'task-character-template-1', + }) + + const taskListener = taskChannel.listener + if (!taskListener) throw new Error('expected the task subscription to be active') + taskListener({ + taskId: 'task-character-template-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + await Promise.resolve() + + const completed = store.get(created.id) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'passed', + taskId: null, + output: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'template-candidate'), + ).toMatchObject({ status: 'active' }) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts new file mode 100644 index 00000000..8bfa0c5c --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' + +import type { MediaReference } from '@/entities' + +import { + acceptUploadedCharacterTemplateState, + advanceCharacterSetupState, + approveReviewState, + createWorkflowRunState, + restartWorkflowRunState, + updateCharacterSetupState, +} from './workflow-state' + +const CREATED_AT = '2026-07-31T02:40:00.000Z' + +function createRun() { + return createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }, + { + runId: 'run-1', + revisionId: 'revision-1', + createdAt: CREATED_AT, + }, + ) +} + +describe('workflow state transitions', () => { + it('creates the fixed five-step workflow and keeps export outside the step sequence', () => { + const run = createRun() + + expect(run).toMatchObject({ + id: 'run-1', + projectId: 'project-1', + status: 'active', + prompt: 'pixel knight', + currentRevisionId: 'revision-1', + }) + expect(run.revisions[0]?.createdAt).toBe(CREATED_AT) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'active' }, + { type: 'character-template', status: 'locked' }, + { type: 'template-candidate', status: 'locked' }, + { type: 'action-generation', status: 'locked' }, + { type: 'review', status: 'locked' }, + ]) + expect(run.revisions[0]?.exportStatus).toBe('not_exported') + expect(run.revisions[0]?.steps[0]?.input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + }) + + it('starts add_action directly at action generation for the existing outfit', () => { + const run = createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '挥手打招呼', + characterId: 'character-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: [], + }, + { + runId: 'run-action-1', + revisionId: 'revision-action-1', + createdAt: CREATED_AT, + }, + ) + + expect(run).toMatchObject({ + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + prompt: '挥手打招呼', + }) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'passed' }, + { type: 'character-template', status: 'passed' }, + { type: 'template-candidate', status: 'passed' }, + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + }) + + it('normalizes character setup input before storing it', () => { + const updated = updateCharacterSetupState(createRun(), { + description: ' revised knight ', + referenceMedia: [], + }) + + expect(updated.revisions[0]?.steps[0]?.input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + }) + + it('accepts an uploaded character template without fabricating an image generation task', () => { + const accepted = acceptUploadedCharacterTemplateState( + createRun(), + 'https://cdn.example.com/uploaded-character.png' as MediaReference, + ) + const revision = accepted.revisions[0]! + + expect(revision.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'passed' }, + { type: 'character-template', status: 'passed' }, + { type: 'template-candidate', status: 'passed' }, + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + expect(revision.steps[0]?.input).toEqual({ + description: '使用上传角色母版', + referenceMedia: ['https://cdn.example.com/uploaded-character.png'], + }) + expect(revision.steps[1]).toMatchObject({ + type: 'character-template', + taskId: null, + submissionId: null, + input: null, + output: { + type: 'character_template', + images: [{ url: 'https://cdn.example.com/uploaded-character.png' }], + }, + }) + expect(revision.steps[2]?.output).toEqual({ + selectedImageUrl: 'https://cdn.example.com/uploaded-character.png', + }) + expect(revision.generationStatus).toBe('not_started') + expect(revision.steps.filter((step) => step.status === 'active')).toHaveLength(1) + }) + + it('activates character-template with its generation input snapshot', () => { + const run = updateCharacterSetupState(createRun(), { + description: 'revised knight', + referenceMedia: [], + }) + + const transitioned = advanceCharacterSetupState(run, { width: 64, height: 64 }) + + expect(transitioned.target).toEqual({ + revisionId: 'revision-1', + stepId: 'revision-1:character-template', + }) + expect(transitioned.run.revisions[0]?.steps.slice(0, 3)).toMatchObject([ + { type: 'character-setup', status: 'passed' }, + { + type: 'character-template', + status: 'active', + input: { + type: 'character_template', + projectId: 'project-1', + prompt: 'revised knight', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }, + }, + { type: 'template-candidate', status: 'locked' }, + ]) + }) + + it('creates a new revision from a passed stage without retaining downstream outputs', () => { + const prepared = advanceCharacterSetupState(createRun(), { width: 64, height: 64 }).run + const sourceRevision = prepared.revisions[0]! + const run = { + ...prepared, + revisions: [ + { + ...sourceRevision, + steps: sourceRevision.steps.map((step) => + step.type === 'character-template' + ? { ...step, status: 'passed' as const } + : step.type === 'template-candidate' + ? { ...step, status: 'active' as const } + : step, + ), + }, + ], + } + + const restarted = restartWorkflowRunState(run, 'revision-1:character-template', { + revisionId: 'revision-2', + createdAt: '2026-07-31T03:00:00.000Z', + }) + + expect(restarted).toMatchObject({ + status: 'active', + currentRevisionId: 'revision-2', + }) + expect(restarted.revisions).toHaveLength(2) + expect(restarted.revisions[0]?.status).toBe('abandoned') + expect(restarted.revisions[1]).toMatchObject({ + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + }) + expect( + restarted.revisions[1]?.steps.map(({ type, status, referenceStepIds }) => ({ + type, + status, + referenceStepIds, + })), + ).toEqual([ + { + type: 'character-setup', + status: 'passed', + referenceStepIds: ['revision-1:character-setup'], + }, + { + type: 'character-template', + status: 'active', + referenceStepIds: ['revision-1:character-template'], + }, + { type: 'template-candidate', status: 'locked', referenceStepIds: [] }, + { type: 'action-generation', status: 'locked', referenceStepIds: [] }, + { type: 'review', status: 'locked', referenceStepIds: [] }, + ]) + }) + + it('rejects a restart from a stage that has not passed', () => { + expect(() => + restartWorkflowRunState(createRun(), 'revision-1:character-template', { + revisionId: 'revision-2', + createdAt: '2026-07-31T03:00:00.000Z', + }), + ).toThrow('只能从已通过的步骤重新开始') + }) + + it('completes the revision and run when the active review is approved', () => { + const run = createRun() + const readyForReview = { + ...run, + revisions: run.revisions.map((revision) => ({ + ...revision, + generationStatus: 'completed' as const, + steps: revision.steps.map((step) => ({ + ...step, + status: step.type === 'review' ? ('active' as const) : ('passed' as const), + })), + })), + } + + const completed = approveReviewState(readyForReview) + + expect(completed.status).toBe('completed') + expect(completed.revisions[0]?.status).toBe('completed') + expect(completed.revisions[0]?.steps.every((step) => step.status === 'passed')).toBe(true) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts new file mode 100644 index 00000000..234d1de9 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,584 @@ +import { + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CompleteAnimationGenerationInput, + type CompleteAnimationGenerationResult, + type CreateWorkflowRunInput, + type MediaReference, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = CreateWorkflowRunInput + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export interface WorkflowStepTarget { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +export interface RestartWorkflowRunStateOptions { + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + const steps = createInitialSteps(input, revisionId, prompt) + + return { + id: runId, + projectId: input.projectId, + characterId: input.purpose === 'add_action' ? input.characterId : null, + outfitId: input.purpose === 'add_action' ? input.outfitId : null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + }, + ], + prompt, + } +} + +function createInitialSteps( + input: CreateWorkflowRunStateInput, + revisionId: string, + prompt: string | null, +): WorkflowStep[] { + const steps = WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ) + if (input.purpose === 'create_character') return steps + + return steps.map((step) => { + if (step.type === 'character-setup') { + return { + ...step, + status: 'passed' as const, + input: { + description: prompt ?? '为已有角色添加动作', + referenceMedia: [], + }, + } + } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed' as const, + output: { + type: 'character_template' as const, + images: [{ url: input.characterTemplateUrl }], + }, + } + } + if (step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl: input.characterTemplateUrl }, + } + } + if (step.type === 'action-generation') { + return { ...step, status: 'active' as const } + } + return step + }) +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision +} + +export function getActiveStep(revision: WorkflowRevision): WorkflowStep | null { + return revision.steps.find((step) => step.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, +): WorkflowRun { + return { + ...run, + revisions: run.revisions.map((revision) => { + if (revision.id !== revisionId) return revision + const nextRevision = { + ...revision, + steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), + } + return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision + }), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupStepInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.type === 'character-setup') + if (!step || step.type !== 'character-setup' || step.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料步骤') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +/** + * 采用用户已上传的角色母版,明确跳过角色图片生成和候选选择。 + * + * 该转换不创建 Generation 任务;上传图片只作为已提供的输入和已确认母版记录。 + * 动作任务仍由随后的 startActionGeneration 正常提交。 + */ +export function acceptUploadedCharacterTemplateState( + workflow: WorkflowRun, + templateUrl: MediaReference, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep || activeStep.type !== 'character-setup') { + throw new Error('当前只能在角色资料步骤采用上传母版') + } + + const normalizedUrl = String(templateUrl).trim() + if (!normalizedUrl) throw new Error('上传角色母版引用不能为空') + const mediaReference = normalizedUrl as MediaReference + + return { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((step) => { + if (step.type === 'character-setup') { + return { + ...step, + status: 'passed' as const, + input: { + description: '使用上传角色母版', + referenceMedia: [mediaReference], + }, + } + } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed' as const, + input: null, + output: { + type: 'character_template' as const, + images: [{ url: normalizedUrl }], + }, + } + } + if (step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl: normalizedUrl }, + } + } + if (step.type === 'action-generation') { + return { ...step, status: 'active' as const } + } + return step + }), + } + }), + } +} + +export function advanceCharacterSetupState( + workflow: WorkflowRun, + spriteSize: { width: number; height: number }, +): { + run: WorkflowRun + target: WorkflowStepTarget +} { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + if (activeStep.type !== 'character-setup') { + throw new Error(`当前步骤不是角色资料:${activeStep.type}`) + } + if (!activeStep.input) throw new Error('请先填写角色资料') + + const templateStep = revision.steps.find((step) => step.type === 'character-template') + if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') + + const generationInput: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: activeStep.input.description, + referenceMedia: activeStep.input.referenceMedia, + spriteWidth: spriteSize.width, + spriteHeight: spriteSize.height, + } + + return { + run: { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + generationStatus: 'in_progress' as const, + steps: item.steps.map((step) => { + if (step.id === activeStep.id) return { ...step, status: 'passed' as const } + if (step.id !== templateStep.id || step.type !== 'character-template') return step + return { + ...step, + status: 'active' as const, + input: generationInput, + } + }), + } + }), + }, + target: { + revisionId: revision.id, + stepId: templateStep.id, + }, + } +} + +/** + * 确认候选选择:标记 template-candidate 为 passed,激活下一个步骤。 + */ +export function confirmCandidateState(run: WorkflowRun, selectedImageUrl: string): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + const revision = getCurrentRevision(run) + const candidateStep = revision.steps.find((step) => step.type === 'template-candidate') + if (!candidateStep || candidateStep.status !== 'active') { + throw new Error('当前只能确认处于 active 状态的候选步骤') + } + + const nextIndex = WORKFLOW_STEP_ORDER.indexOf('template-candidate') + 1 + const nextType = WORKFLOW_STEP_ORDER[nextIndex] + + return { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((step) => { + if (step.id === candidateStep.id && step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl }, + } + } + if (nextType && step.type === nextType) { + return { ...step, status: 'active' as const } + } + return step + }), + } + }), + } +} + +/** + * 动作生成完成:与 confirmCandidateState 对称。 + * + * 成功时把 action-generation 标记 passed 并激活 review 步骤;失败时标记 failed + * 并把整个 run 置为 failed。两个方向都保证「active 状态的 run 恰好有一个 active + * 步骤」,让刷新后的存储校验能够恢复这条运行记录。 + */ +export function completeActionGenerationState( + run: WorkflowRun, + result: CompleteAnimationGenerationResult | { error: string }, +): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可完成动作生成:${run.status}`) + const revision = getCurrentRevision(run) + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + if (!actionStep || actionStep.status !== 'active') { + throw new Error('当前只能完成处于 active 状态的动作生成步骤') + } + + const failed = result !== null && typeof result === 'object' && 'error' in result + const reviewStep = revision.steps.find((step) => step.type === 'review') + + const updated = replaceWorkflowStep( + run, + revision.id, + actionStep.id, + (current) => { + if (current.type !== 'action-generation') return current + return { + ...current, + status: failed ? ('failed' as const) : ('passed' as const), + output: failed ? null : result, + error: failed ? String((result as { error: string }).error) : null, + // 任务已终态,解除任务 ID 关联(存储校验要求终态步骤不持有任务 ID) + taskId: null, + submissionId: null, + } + }, + (current) => ({ + ...current, + status: failed ? ('failed' as const) : current.status, + generationStatus: failed ? ('failed' as const) : ('completed' as const), + steps: current.steps.map((step) => { + if (failed || !reviewStep || step.id !== reviewStep.id || step.type !== 'review') { + return step + } + return { ...step, status: 'active' as const } + }), + }), + ) + + return failed ? { ...updated, status: 'failed' as const } : updated +} + +/** 审核通过后结束当前版本和整条运行;发布与下载仍由后续独立功能处理。 */ +export function approveReviewState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可审核:${run.status}`) + const revision = getCurrentRevision(run) + const reviewStep = revision.steps.find((step) => step.type === 'review') + if (!reviewStep || reviewStep.status !== 'active') { + throw new Error('当前只能通过处于 active 状态的审核步骤') + } + + return { + ...run, + status: 'completed', + revisions: run.revisions.map((item) => + item.id === revision.id + ? { + ...item, + status: 'completed', + steps: item.steps.map((step) => + step.id === reviewStep.id + ? { ...step, status: 'passed' as const, error: null } + : step, + ), + } + : item, + ), + } +} + +/** + * 记录动作生成任务 ID:步骤保持 active,只是把 taskId 落盘,供刷新后 resume 恢复。 + */ +export function beginActionGenerationState( + run: WorkflowRun, + input: CompleteAnimationGenerationInput, + submissionId: string, +): WorkflowRun { + const revision = getCurrentRevision(requireActiveWorkflow(run)) + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + if (!actionStep || actionStep.status !== 'active' || actionStep.taskId) { + throw new Error('当前动作生成步骤不可重复提交') + } + return replaceWorkflowStep(run, revision.id, actionStep.id, (current) => { + if (current.type !== 'action-generation') return current + return { ...current, input, submissionId, error: null } + }) +} + +export function recordActionGenerationTaskState( + run: WorkflowRun, + taskId: string, + input?: CompleteAnimationGenerationInput, +): WorkflowRun { + if (run.status !== 'active' && run.status !== 'interrupted') { + throw new Error(`WorkflowRun 当前不可记录任务:${run.status}`) + } + const revision = getCurrentRevision(run) + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + if (!actionStep || actionStep.status !== 'active') { + throw new Error('当前只能为 active 状态的动作生成步骤记录任务') + } + return replaceWorkflowStep(run, revision.id, actionStep.id, (current) => { + if (current.type !== 'action-generation') return current + return { ...current, taskId, input: input ?? current.input, submissionId: null } + }) +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +/** + * 从已通过节点开启新的执行线。 + * + * 旧 Revision 保留为只读历史;重开点之前的结果作为新线参考,重开点及之后的结果 + * 不会进入新线。流程节点始终固定为五个,因此“移除下游”在数据中表现为清空它们的 + * 输入、输出与引用,并重新锁定。 + */ +export function restartWorkflowRunState( + run: WorkflowRun, + restartStepId: WorkflowStep['id'], + { revisionId, createdAt }: RestartWorkflowRunStateOptions, +): WorkflowRun { + const sourceRevision = getCurrentRevision(run) + const restartIndex = sourceRevision.steps.findIndex((step) => step.id === restartStepId) + const restartStep = sourceRevision.steps[restartIndex] + if (!restartStep || restartStep.status !== 'passed') { + throw new Error('只能从已通过的步骤重新开始') + } + + const steps = sourceRevision.steps.map((step, index) => { + if (index < restartIndex) return copyReferenceStep(step, revisionId) + if (index === restartIndex) return createRestartStep(step, revisionId) + + return lockFreshStep(step.type, revisionId, index, run.prompt) + }) + + const revision: WorkflowRevision = { + id: revisionId, + basedOnRevisionId: sourceRevision.id, + restartStepId: restartStep.id, + status: 'active', + steps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + } + + return { + ...run, + status: 'active', + currentRevisionId: revision.id, + revisions: [ + ...run.revisions.map((item) => + item.id === sourceRevision.id ? { ...item, status: 'abandoned' as const } : item, + ), + revision, + ], + } +} + +function copyReferenceStep(step: WorkflowStep, revisionId: WorkflowRevision['id']): WorkflowStep { + const source = structuredClone(step) + return { + ...source, + id: `${revisionId}:${source.type}`, + status: 'passed', + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [step.id], + } +} + +function createRestartStep(step: WorkflowStep, revisionId: WorkflowRevision['id']): WorkflowStep { + const source = structuredClone(step) + return { + ...source, + id: `${revisionId}:${source.type}`, + status: 'active', + taskId: null, + submissionId: null, + error: null, + output: null, + referenceStepIds: [step.id], + } as WorkflowStep +} + +function lockFreshStep( + type: WorkflowStepType, + revisionId: WorkflowRevision['id'], + index: number, + prompt: string | null, +): WorkflowStep { + return { + ...createInitialStep(type, revisionId, index, prompt), + status: 'locked', + referenceStepIds: [], + } +} + +function createInitialStep( + type: WorkflowStepType, + revisionId: string, + index: number, + prompt: string | null, +): WorkflowStep { + const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' + const base: { + id: string + status: WorkflowStepStatus + taskId: null + submissionId: null + error: null + referenceStepIds: string[] + } = { + id: `${revisionId}:${type}`, + status, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { + ...base, + type, + input: null, + output: null, + } + } + return { ...base, type, input: null, output: null } as WorkflowStep +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 42c544ae..f80172a2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,105 +6,3 @@ body, #root { height: 100%; } - -@keyframes action-reveal { - from { - opacity: 0; - transform: translateY(-18px) scale(0.97); - clip-path: inset(0 8% 72% round 1.5rem); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - clip-path: inset(0 round 1.5rem); - } -} - -@keyframes projects-intro { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes projects-card-enter { - from { - opacity: 0; - transform: translateY(14px) scale(0.985); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes projects-dialog-backdrop { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes projects-dialog-panel { - from { - opacity: 0; - transform: translateY(8px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes route-transition { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.projects-intro { - animation: projects-intro 420ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.projects-card-enter { - animation: projects-card-enter 500ms cubic-bezier(0.22, 1, 0.36, 1) both; - animation-delay: calc(70ms + var(--project-card-order, 0) * 55ms); -} - -.projects-dialog-backdrop { - animation: projects-dialog-backdrop 180ms ease-out both; -} - -.projects-dialog-panel { - animation: projects-dialog-panel 260ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.route-transition { - animation: route-transition 260ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.action-reveal { - animation: action-reveal 520ms cubic-bezier(0.2, 0.9, 0.25, 1) both; -} - -@media (prefers-reduced-motion: reduce) { - .projects-intro, - .projects-card-enter, - .projects-dialog-backdrop, - .projects-dialog-panel, - .route-transition, - .action-reveal { - animation: none; - } -} diff --git a/frontend/src/pages/asset-library/index.test.tsx b/frontend/src/pages/asset-library/index.test.tsx index 182f1cc4..c2b34de3 100644 --- a/frontend/src/pages/asset-library/index.test.tsx +++ b/frontend/src/pages/asset-library/index.test.tsx @@ -1,9 +1,7 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter } from 'react-router' - -import { AppRoutes } from '@/app' +import { renderAuthenticatedAppRoute } from '@/test/authenticated-app-routes' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { @@ -16,11 +14,7 @@ function renderRoute(route: string) { const backend = createProjectAssetsBackend() vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', backend.fetch) - return render( - - - , - ) + return renderAuthenticatedAppRoute(route) } describe('AssetLibraryPage', () => { @@ -51,11 +45,7 @@ describe('AssetLibraryPage', () => { const backend = createProjectAssetsBackend({ characterCount: 25 }) vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', backend.fetch) - render( - - - , - ) + renderAuthenticatedAppRoute('/projects/42/assets') expect(await screen.findAllByRole('link', { name: /查看角色/ })).toHaveLength(24) fireEvent.click(screen.getByRole('button', { name: '下一页' })) diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index 97cf2ef8..b0f40f13 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -1,17 +1,17 @@ import { useEffect, useState } from 'react' import { Link, useParams } from 'react-router' -import { characterApis, type Character } from '@/entities' +import type { Character, CharacterApis } from '@/entities' import type { Paged } from '@/shared/pagination' import { Pagination } from '@/shared/ui' const CHARACTER_PAGE_SIZE = 24 function characterName(character: Character) { - return character.name ?? '未命名角色' + return character.name?.trim() || character.description?.trim() || `角色 ${character.id}` } -export function AssetLibraryPage() { +export function AssetLibraryPage({ apis }: { apis: CharacterApis }) { const { projectId } = useParams() const [pageNumber, setPageNumber] = useState(1) const [charactersPage, setCharactersPage] = useState | null>(null) @@ -28,23 +28,29 @@ export function AssetLibraryPage() { setCharactersPage(null) setError(null) - void characterApis - .listByProject(projectId, { - page: pageNumber, - pageSize: CHARACTER_PAGE_SIZE, - }) - .then( - (page) => { - if (active) setCharactersPage(page) - }, - () => { - if (active) setError('资产库暂时无法读取') - }, - ) + const pagePromise = apis.listPageByProject + ? apis.listPageByProject(projectId, { + page: pageNumber, + pageSize: CHARACTER_PAGE_SIZE, + }) + : apis.listByProject(projectId).then((items) => ({ + items, + total: items.length, + page: 1, + pageSize: items.length || CHARACTER_PAGE_SIZE, + })) + void pagePromise.then( + (page) => { + if (active) setCharactersPage(page) + }, + () => { + if (active) setError('资产库暂时无法读取') + }, + ) return () => { active = false } - }, [pageNumber, projectId]) + }, [apis, pageNumber, projectId]) return (
@@ -105,9 +111,9 @@ function CharacterGrid({ projectId, characters }: { projectId: string; character className="group overflow-hidden rounded-[1.25rem] border border-[#d7dbd4] bg-white transition hover:border-[#9ca79c]" >
- {outfit?.previewUrl ? ( + {outfit?.characterTemplateUrl ? ( {`${name}的${outfit.name}预览`} diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 993724ea..a1a8759d 100644 --- a/frontend/src/pages/character-detail/index.test.tsx +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -1,9 +1,7 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter } from 'react-router' - -import { AppRoutes } from '@/app' +import { renderAuthenticatedAppRoute } from '@/test/authenticated-app-routes' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { @@ -16,11 +14,7 @@ function renderCharacter(characterId: string) { const backend = createProjectAssetsBackend() vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', backend.fetch) - return render( - - - , - ) + return renderAuthenticatedAppRoute(`/projects/42/assets/${characterId}`) } describe('CharacterDetailPage', () => { diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx index d3e28a87..3cbb406b 100644 --- a/frontend/src/pages/character-detail/index.tsx +++ b/frontend/src/pages/character-detail/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Link, useParams } from 'react-router' -import { characterApis, type Action, type Character, type Outfit } from '@/entities' +import type { Action, Character, CharacterApis, Outfit } from '@/entities' const ACTION_TYPE_LABELS: Record = { walk: '行走', @@ -15,14 +15,14 @@ function actionTypeLabel(type: string) { } function orderedFrames(action: Action) { - return [...action.frames].sort((left, right) => left.index - right.index) + return action.frames } function characterName(character: Character) { - return character.name ?? '未命名角色' + return character.name?.trim() || character.description?.trim() || `角色 ${character.id}` } -export function CharacterDetailPage() { +export function CharacterDetailPage({ apis }: { apis: CharacterApis }) { const { projectId, characterId } = useParams() const [character, setCharacter] = useState(null) const [selectedOutfitId, setSelectedOutfitId] = useState(null) @@ -40,7 +40,7 @@ export function CharacterDetailPage() { setCharacter(null) setSelectedOutfitId(null) setError(null) - void characterApis.get(characterId).then( + void apis.get(characterId).then( (nextCharacter) => { if (!active) return if (nextCharacter.projectId !== projectId) { @@ -58,7 +58,7 @@ export function CharacterDetailPage() { return () => { active = false } - }, [characterId, projectId]) + }, [apis, characterId, projectId]) if (error) { return ( @@ -151,9 +151,9 @@ function OutfitMaster({ character, outfit }: { character: Character; outfit: Out return (
- {outfit.previewUrl ? ( + {outfit.characterTemplateUrl ? ( {`${name}的${outfit.name}预览`} @@ -254,8 +254,8 @@ function ActionList({ character, outfit }: { character: Character; outfit: Outfi {expanded ? '−' : '↗'}

- {actionTypeLabel(action.type)} · {action.fps} FPS · {action.frameCount} 帧 ·{' '} - {action.loop ? '循环' : '单次'} + {actionTypeLabel(action.type)} · {action.fps} FPS · {action.frames.length}{' '} + 帧 · {action.loop ? '循环' : '单次'}

@@ -278,7 +278,7 @@ function ActionList({ character, outfit }: { character: Character; outfit: Outfi

- {selectedAction.fps} FPS · {selectedAction.frameCount} 帧 ·{' '} + {selectedAction.fps} FPS · {selectedAction.frames.length} 帧 ·{' '} {selectedAction.loop ? '循环播放' : '单次播放'}

@@ -309,17 +309,17 @@ function ActionList({ character, outfit }: { character: Character; outfit: Outfi

    - {orderedFrames(selectedAction).map((frame) => ( -
  1. + {orderedFrames(selectedAction).map((frame, frameIndex) => ( +
  2. {`${selectedAction.name}第
    - #{String(frame.index + 1).padStart(2, '0')} + #{String(frameIndex + 1).padStart(2, '0')} {frame.durationMs === null ? `按 ${selectedAction.fps} FPS` diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx new file mode 100644 index 00000000..8152ed59 --- /dev/null +++ b/frontend/src/pages/history/index.tsx @@ -0,0 +1,112 @@ +/** + * 历史记录页面 — 读取 WorkflowRunStore 展示已完成的工作流。 + */ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { WorkflowRun, WorkflowRunStore } from '@/entities' + +export function HistoryPage({ store }: { store: WorkflowRunStore }) { + const { projectId } = useParams() + const [runs, setRuns] = useState(() => filterRuns(store.list(), projectId)) + + useEffect(() => { + setRuns(filterRuns(store.list(), projectId)) + return store.subscribeAll((items) => setRuns(filterRuns(items, projectId))) + }, [projectId, store]) + + const completedRuns = runs.filter((r) => r.status === 'completed') + const activeRuns = runs.filter((r) => r.status === 'active' || r.status === 'interrupted') + + return ( +
    +
    +

    + HISTORY +

    +

    历史记录

    +

    已完成和进行中的创作记录。

    +
    + + {activeRuns.length > 0 && ( +
    +

    进行中

    +
    + {activeRuns.map((run) => ( + + ))} +
    +
    + )} + + {completedRuns.length > 0 && ( +
    +

    已完成

    +
    + {completedRuns.map((run) => ( + + ))} +
    +
    + )} + + {runs.length === 0 && ( +
    +

    还没有创作记录。

    + + 开始创作 + +
    + )} +
    + ) +} + +function filterRuns(runs: WorkflowRun[], projectId?: string) { + return projectId ? runs.filter((run) => run.projectId === projectId) : runs +} + +function RunCard({ run }: { run: WorkflowRun }) { + const revision = run.revisions.find((r) => r.id === run.currentRevisionId) + const passedCount = revision?.steps.filter((s) => s.status === 'passed').length ?? 0 + const totalCount = revision?.steps.length ?? 0 + + const statusLabel = + run.status === 'completed' + ? '已完成' + : run.status === 'failed' + ? '失败' + : run.status === 'interrupted' + ? '已中断' + : '进行中' + + const statusColor = + run.status === 'completed' + ? 'text-[#3d6b4a]' + : run.status === 'failed' + ? 'text-[#8b332a]' + : 'text-[#687069]' + + return ( + +
    +

    + RUN {run.id.slice(0, 8)} +

    +

    + {run.prompt || `项目 ${run.projectId.slice(0, 8)}`} +

    +

    + {passedCount} / {totalCount} 步骤完成 +

    +
    + {statusLabel} + + ) +} diff --git a/frontend/src/pages/home/account-panel.test.tsx b/frontend/src/pages/home/account-panel.test.tsx new file mode 100644 index 00000000..79d5e882 --- /dev/null +++ b/frontend/src/pages/home/account-panel.test.tsx @@ -0,0 +1,390 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, useLocation } from 'react-router' + +import type { AuthSessionValue } from '@/features/auth-session' +import { useAuthSession } from '@/features/auth-session' +import { AccountPanel } from './account-panel' + +vi.mock('@/features/auth-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAuthSession: vi.fn() } +}) + +const mockedUseAuthSession = vi.mocked(useAuthSession) + +function createSession( + state: AuthSessionValue['state'] = { status: 'guest', user: null }, +): AuthSessionValue { + const user = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal' as const, + } + const tokens = { accessToken: 'access', refreshToken: 'refresh', user } + return { + state, + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens), + login: vi.fn(async () => tokens), + loginByCode: vi.fn(async () => tokens), + changePassword: vi.fn(async () => undefined), + logout: vi.fn(async () => undefined), + } +} + +function LocationProbe() { + const location = useLocation() + return {location.pathname + location.search} +} + +function renderPanel(initialEntry = '/?account=login', onClose = vi.fn()) { + return render( + + + + , + ) +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + mockedUseAuthSession.mockReset() + mockedUseAuthSession.mockReturnValue(createSession()) +}) + +describe('AccountPanel', () => { + it('本地模式预填开发账户,一次点击即可进入工作台', async () => { + vi.stubEnv('VITE_AUTH_MODE', 'local') + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2Fquick-start') + + expect((screen.getByLabelText('邮箱') as HTMLInputElement).value).toBe('local@windup.dev') + fireEvent.click(screen.getByRole('button', { name: '进入本地工作台' })) + + await waitFor(() => + expect(session.loginByCode).toHaveBeenCalledWith({ + email: 'local@windup.dev', + code: 'local', + }), + ) + await waitFor(() => expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start')) + }) + + it('发送登录验证码并用验证码登录后恢复安全站内目标', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2Fprojects%3Fpage%3D2') + + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + + await waitFor(() => + expect(session.sendCode).toHaveBeenCalledWith({ + email: 'ada@example.test', + purpose: 'login', + }), + ) + expect( + (screen.getByRole('button', { name: '60 秒后可重发' }) as HTMLButtonElement).disabled, + ).toBe(true) + + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '使用验证码登录' })) + + await waitFor(() => + expect(session.loginByCode).toHaveBeenCalledWith({ + email: 'ada@example.test', + code: '123456', + }), + ) + await waitFor(() => + expect(screen.getByLabelText('当前路径').textContent).toBe('/projects?page=2'), + ) + }) + + it('密码登录也提交验证码,且拒绝跳转到协议相对地址', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2F%2Fevil.example') + + fireEvent.click(screen.getByRole('button', { name: '密码登录' })) + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password123' } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '654321' } }) + fireEvent.click(screen.getByRole('button', { name: '使用密码登录' })) + + await waitFor(() => + expect(session.login).toHaveBeenCalledWith({ + email: 'ada@example.test', + password: 'password123', + code: '654321', + }), + ) + await waitFor(() => expect(screen.getByLabelText('当前路径').textContent).toBe('/')) + }) + + it('拒绝包含反斜杠的 returnTo,避免浏览器将其规范化为外站', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=login&returnTo=%2F%5Cevil.example') + + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '使用验证码登录' })) + + await waitFor(() => expect(screen.getByLabelText('当前路径').textContent).toBe('/')) + }) + + it('密码与验证码登录按钮暴露当前选中状态', () => { + renderPanel() + + expect(screen.getByRole('button', { name: '验证码登录' }).getAttribute('aria-pressed')).toBe( + 'true', + ) + expect(screen.getByRole('button', { name: '密码登录' }).getAttribute('aria-pressed')).toBe( + 'false', + ) + + fireEvent.click(screen.getByRole('button', { name: '密码登录' })) + expect(screen.getByRole('button', { name: '验证码登录' }).getAttribute('aria-pressed')).toBe( + 'false', + ) + expect(screen.getByRole('button', { name: '密码登录' }).getAttribute('aria-pressed')).toBe( + 'true', + ) + }) + + it('非法邮箱不能绕过原生约束发送验证码', () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel() + + const emailInput = screen.getByLabelText('邮箱') as HTMLInputElement + fireEvent.change(emailInput, { target: { value: 'not-an-email' } }) + expect(emailInput.checkValidity()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + + expect(session.sendCode).not.toHaveBeenCalled() + }) + + it('使用 register purpose 发码并提交可选昵称完成注册', async () => { + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=register') + + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'new@example.test' }, + }) + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await waitFor(() => + expect(session.sendCode).toHaveBeenCalledWith({ + email: 'new@example.test', + purpose: 'register', + }), + ) + + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '112233' } }) + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password123' } }) + fireEvent.change(screen.getByLabelText('昵称(可选)'), { target: { value: ' 新用户 ' } }) + fireEvent.click(screen.getByRole('button', { name: '创建账户' })) + + await waitFor(() => + expect(session.register).toHaveBeenCalledWith({ + email: 'new@example.test', + password: 'password123', + code: '112233', + nickname: '新用户', + }), + ) + }) + + it('只读展示账户资料,不提供头像、昵称或邮箱编辑与删号入口', () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'banned', + }, + }) + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings') + + expect(screen.getByRole('heading', { name: '个人设置' })).toBeTruthy() + expect(screen.getByText('ada@example.test')).toBeTruthy() + expect(screen.getByText('Ada')).toBeTruthy() + expect(screen.getByText('未验证')).toBeTruthy() + expect(screen.getByText('已封禁')).toBeTruthy() + expect(screen.queryByLabelText('邮箱')).toBeNull() + expect(screen.queryByLabelText('昵称')).toBeNull() + expect(screen.queryByText(/头像|删除账户|OAuth|第三方登录/)).toBeNull() + }) + + it('修改密码成功后提示重新登录并返回登录模式', async () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', + }, + }) + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings') + + fireEvent.change(screen.getByLabelText('当前密码'), { target: { value: 'old-password' } }) + fireEvent.change(screen.getByLabelText('新密码'), { target: { value: 'new-password' } }) + fireEvent.click(screen.getByRole('button', { name: '修改密码' })) + + await waitFor(() => + expect(session.changePassword).toHaveBeenCalledWith({ + oldPassword: 'old-password', + newPassword: 'new-password', + }), + ) + expect(await screen.findByText('密码已修改,请重新登录')).toBeTruthy() + expect(screen.getByRole('heading', { name: '登录 Windup' })).toBeTruthy() + }) + + it('退出登录,并将请求错误通过 alert 呈现', async () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', + }, + }) + session.logout = vi.fn(async () => { + throw new Error('退出服务暂不可用') + }) + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings') + + fireEvent.click(screen.getByRole('button', { name: '退出登录' })) + + expect((await screen.findByRole('alert')).textContent).toContain('退出服务暂不可用') + }) + + it('退出登录成功后关闭面板', async () => { + const session = createSession({ + status: 'authenticated', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', + }, + }) + const onClose = vi.fn() + mockedUseAuthSession.mockReturnValue(session) + renderPanel('/?account=settings', onClose) + + fireEvent.click(screen.getByRole('button', { name: '退出登录' })) + + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('验证码倒计时逐秒推进,并在 60 秒后恢复发码', async () => { + vi.useFakeTimers() + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + renderPanel() + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '60 秒后可重发' })).toBeTruthy() + + act(() => vi.advanceTimersByTime(1_000)) + expect(screen.getByRole('button', { name: '59 秒后可重发' })).toBeTruthy() + for (let second = 0; second < 59; second += 1) { + act(() => vi.advanceTimersByTime(1_000)) + } + + expect((screen.getByRole('button', { name: '发送验证码' }) as HTMLButtonElement).disabled).toBe( + false, + ) + }) + + it('面板卸载时清理尚未结束的倒计时', async () => { + vi.useFakeTimers() + const session = createSession() + mockedUseAuthSession.mockReturnValue(session) + const view = renderPanel() + const timersBeforeCountdown = vi.getTimerCount() + fireEvent.change(screen.getByLabelText('邮箱'), { + target: { value: 'ada@example.test' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })) + await Promise.resolve() + }) + expect(vi.getTimerCount()).toBeGreaterThan(timersBeforeCountdown) + view.unmount() + + expect(vi.getTimerCount()).toBe(timersBeforeCountdown) + }) + + it('请求处理中禁用提交,避免重复提交', async () => { + let resolveLogin: (() => void) | undefined + const session = createSession() + session.loginByCode = vi.fn( + () => + new Promise>>((resolve) => { + resolveLogin = () => + resolve({ + accessToken: 'access', + refreshToken: 'refresh', + user: { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: null, + status: 'normal', + }, + }) + }), + ) + mockedUseAuthSession.mockReturnValue(session) + renderPanel() + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'ada@example.test' } }) + fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '使用验证码登录' })) + + await waitFor(() => + expect( + (screen.getByRole('button', { name: '正在登录…' }) as HTMLButtonElement).disabled, + ).toBe(true), + ) + resolveLogin?.() + }) +}) diff --git a/frontend/src/pages/home/account-panel.tsx b/frontend/src/pages/home/account-panel.tsx new file mode 100644 index 00000000..d0e932d5 --- /dev/null +++ b/frontend/src/pages/home/account-panel.tsx @@ -0,0 +1,570 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate, useSearchParams } from 'react-router' + +import { resolveAuthMode, useAuthSession, type AuthSessionValue } from '@/features/auth-session' + +type PanelMode = 'code-login' | 'password-login' | 'register' | 'settings' +type PendingAction = 'send-code' | 'authenticate' | 'change-password' | 'logout' | null + +interface AccountPanelProps { + onClose(): void +} + +const fieldClassName = + 'mt-2 w-full rounded-xl border border-[#c7cbc3] bg-[#f7f6f0] px-3 py-2.5 text-sm text-[#191b18] outline-none transition focus:border-[#263f2d] focus:ring-2 focus:ring-[#263f2d]/15' +const primaryButtonClassName = + 'inline-flex min-h-11 items-center justify-center rounded-xl bg-[#1d211d] px-4 text-sm font-semibold text-white transition hover:bg-[#2b322b] disabled:cursor-not-allowed disabled:opacity-50' +const quietButtonClassName = + 'rounded-lg px-3 py-2 text-sm font-semibold text-[#536052] transition hover:bg-[#e4e7e0] disabled:cursor-not-allowed disabled:opacity-50' + +export function AccountPanel({ onClose }: AccountPanelProps) { + const session = useAuthSession() + const localAuth = resolveAuthMode() === 'local' + const navigate = useNavigate() + const dialogRef = useRef(null) + const onCloseRef = useRef(onClose) + const [searchParams, setSearchParams] = useSearchParams() + const [mode, setMode] = useState(() => modeFromQuery(searchParams.get('account'))) + const [email, setEmail] = useState(localAuth ? 'local@windup.dev' : '') + const [code, setCode] = useState(localAuth ? 'local' : '') + const [password, setPassword] = useState('') + const [nickname, setNickname] = useState('') + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [countdown, setCountdown] = useState(0) + const [pending, setPending] = useState(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useEffect(() => { + const previouslyFocused = + document.activeElement instanceof HTMLElement ? document.activeElement : null + const dialog = dialogRef.current + getFocusableElements(dialog)[0]?.focus() + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onCloseRef.current() + return + } + if (event.key !== 'Tab') return + + const focusableElements = getFocusableElements(dialogRef.current) + const first = focusableElements[0] + const last = focusableElements.at(-1) + if (!first || !last) return + + const activeElement = document.activeElement + if ( + event.shiftKey && + (activeElement === first || !dialogRef.current?.contains(activeElement)) + ) { + event.preventDefault() + last.focus() + } else if ( + !event.shiftKey && + (activeElement === last || !dialogRef.current?.contains(activeElement)) + ) { + event.preventDefault() + first.focus() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + previouslyFocused?.focus() + } + }, []) + + useEffect(() => { + if (countdown <= 0) return + const timer = window.setTimeout( + () => setCountdown((seconds) => Math.max(0, seconds - 1)), + 1_000, + ) + return () => window.clearTimeout(timer) + }, [countdown]) + + const updateMode = (nextMode: PanelMode) => { + setMode(nextMode) + setError(null) + setNotice(null) + const nextParams = new URLSearchParams(searchParams) + nextParams.set( + 'account', + nextMode === 'register' ? 'register' : nextMode === 'settings' ? 'settings' : 'login', + ) + setSearchParams(nextParams, { replace: true }) + } + + const sendCode = async () => { + setPending('send-code') + setError(null) + try { + await session.sendCode({ + email: email.trim(), + purpose: mode === 'register' ? 'register' : 'login', + }) + setCountdown(60) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const authenticate = async (event: FormEvent) => { + event.preventDefault() + setPending('authenticate') + setError(null) + try { + if (mode === 'register') { + const trimmedNickname = nickname.trim() + await session.register({ + email: email.trim(), + password, + code: code.trim(), + ...(trimmedNickname ? { nickname: trimmedNickname } : {}), + }) + } else if (mode === 'password-login') { + await session.login({ email: email.trim(), password, code: code.trim() }) + } else { + await session.loginByCode({ email: email.trim(), code: code.trim() }) + } + + const returnTo = resolveSafeReturnTo(searchParams.get('returnTo')) + navigate(returnTo ?? '/', { replace: true }) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const changePassword = async (event: FormEvent) => { + event.preventDefault() + setPending('change-password') + setError(null) + try { + await session.changePassword({ oldPassword, newPassword }) + setOldPassword('') + setNewPassword('') + updateMode('code-login') + setNotice('密码已修改,请重新登录') + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const logout = async () => { + setPending('logout') + setError(null) + try { + await session.logout() + onClose() + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setPending(null) + } + } + + const authenticatedUser = session.state.status === 'authenticated' ? session.state.user : null + const showSettings = mode === 'settings' && authenticatedUser !== null + const isBusy = pending !== null + + return createPortal( +
    + +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} + {notice ? ( +

+ {notice} +

+ ) : null} + + {showSettings && authenticatedUser ? ( + + ) : ( + + )} +
+ + , + document.body, + ) +} + +interface AuthPanelProps { + localAuth: boolean + mode: Exclude + email: string + code: string + password: string + nickname: string + countdown: number + pending: PendingAction + isBusy: boolean + onModeChange(mode: PanelMode): void + onEmailChange(value: string): void + onCodeChange(value: string): void + onPasswordChange(value: string): void + onNicknameChange(value: string): void + onSendCode(): void + onSubmit(event: FormEvent): void +} + +function AuthPanel(props: AuthPanelProps) { + const emailInputRef = useRef(null) + const isRegister = props.mode === 'register' + const isPasswordLogin = props.mode === 'password-login' + + const sendCode = () => { + if (!emailInputRef.current?.reportValidity()) return + props.onSendCode() + } + + return ( + <> +
+
+

+ {isRegister ? '注册 Windup' : '登录 Windup'} +

+

+ {props.localAuth && !isRegister + ? '当前使用本地开发账户,不会连接真实认证服务。' + : isRegister + ? '创建账户,保存并继续你的制作。' + : '登录后返回刚才的工作位置。'} +

+
+
+ + {isRegister ? ( + + ) : props.localAuth ? ( +

+ 开发账号已经准备好,直接进入工作台即可。 +

+ ) : ( +
+ + +
+ )} + +
+ + + {!props.localAuth ? ( + + ) : null} + + {isPasswordLogin || isRegister ? ( + + ) : null} + + {isRegister ? ( + + ) : null} + + +
+ + {!isRegister && !props.localAuth ? ( +

+ 还没有账户?{' '} + +

+ ) : null} + + ) +} + +interface SettingsPanelProps { + user: Extract['user'] + oldPassword: string + newPassword: string + pending: PendingAction + onOldPasswordChange(value: string): void + onNewPasswordChange(value: string): void + onChangePassword(event: FormEvent): void + onLogout(): void +} + +function SettingsPanel(props: SettingsPanelProps) { + const disabled = props.pending !== null + return ( + <> +

个人设置

+

查看账户资料并管理登录安全。

+ +
+ + + + +
+ +
+
+

修改密码

+

修改成功后需要重新登录。

+
+ + + +
+ + + + ) +} + +function ProfileRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function modeFromQuery(value: string | null): PanelMode { + if (value === 'register') return 'register' + if (value === 'settings') return 'settings' + return 'code-login' +} + +function resolveSafeReturnTo(value: string | null): string | null { + if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) { + return null + } + + try { + const currentOrigin = globalThis.location.origin + const resolved = new URL(value, currentOrigin) + if (resolved.origin !== currentOrigin) return null + return `${resolved.pathname}${resolved.search}${resolved.hash}` + } catch { + return null + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : '请求失败,请稍后重试' +} + +function getFocusableElements(container: HTMLElement | null): HTMLElement[] { + if (!container) return [] + return Array.from( + container.querySelectorAll( + 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0) +} diff --git a/frontend/src/pages/home/brand-bird.tsx b/frontend/src/pages/home/brand-bird.tsx deleted file mode 100644 index 0fc9ef37..00000000 --- a/frontend/src/pages/home/brand-bird.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useEffect, useRef } from 'react' - -const SAMPLE_SIZE = 48 -const MAX_DPR = 2 -const WAVE_DURATION = 7200 - -interface BrandPoint { - accent: boolean - luminance: number - x: number - y: number -} - -function smoothStep(value: number) { - return value * value * (3 - 2 * value) -} - -function sampleMark(image: HTMLImageElement): BrandPoint[] { - const sample = document.createElement('canvas') - sample.width = SAMPLE_SIZE - sample.height = SAMPLE_SIZE - const context = sample.getContext('2d', { willReadFrequently: true }) - if (!context) return [] - - context.drawImage(image, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE) - const pixels = context.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data - const points: BrandPoint[] = [] - - for (let y = 0; y < SAMPLE_SIZE; y += 1) { - for (let x = 0; x < SAMPLE_SIZE; x += 1) { - if (pixels[(y * SAMPLE_SIZE + x) * 4 + 3] < 80) continue - const centerLight = Math.max( - 0, - 1 - Math.hypot(x / SAMPLE_SIZE - 0.5, y / SAMPLE_SIZE - 0.5) / 0.7, - ) - points.push({ - accent: Math.hypot(x - SAMPLE_SIZE * 0.44, y - SAMPLE_SIZE * 0.5) < SAMPLE_SIZE * 0.055, - luminance: 0.34 + centerLight * 0.3, - x: x / (SAMPLE_SIZE - 1), - y: y / (SAMPLE_SIZE - 1), - }) - } - } - - return points -} - -/** livedemo 主屏点阵小鸟;仅作为主页背景,不接入页面业务。 */ -export function HomeBrandBird() { - const canvasRef = useRef(null) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas || typeof ResizeObserver === 'undefined') return - const context = canvas.getContext('2d') - if (!context) return - - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - const image = new Image() - image.decoding = 'async' - image.src = '/windup-mark.svg' - - let animationFrame = 0 - let points: BrandPoint[] = [] - let width = 1 - let height = 1 - - function draw(time = 0) { - context!.clearRect(0, 0, width, height) - if (!points.length) return - - const markSize = Math.min(width * 0.36, height * 0.66, 576) - const originX = Math.max(18, width * 0.02) - const originY = height - markSize * 0.9 - const dotRadius = Math.max(1.15, Math.min(3.6, markSize / 155)) - const waveProgress = reduceMotion ? 0.5 : (time % WAVE_DURATION) / WAVE_DURATION - const primaryCenter = originX + (waveProgress * 1.42 - 0.2) * markSize - const echoProgress = (waveProgress + 0.52) % 1 - const echoCenter = originX + (echoProgress * 1.42 - 0.2) * markSize - const spread = markSize * 0.15 - - for (const point of points) { - const baseX = originX + point.x * markSize - const baseY = originY + point.y * markSize - const primaryDistance = baseX - primaryCenter - const echoDistance = baseX - echoCenter - const primaryWave = reduceMotion - ? 0 - : Math.sin(primaryDistance / 14) * Math.exp(-((primaryDistance / spread) ** 2)) - const echoWave = reduceMotion - ? 0 - : Math.sin(echoDistance / 17) * Math.exp(-((echoDistance / spread) ** 2)) * 0.42 - const displacement = (primaryWave + echoWave) * 4.2 * (0.38 + smoothStep(point.x) * 0.62) - const lightBand = primaryWave * 0.2 + echoWave * 0.1 - const opacity = Math.max(0.16, Math.min(0.92, point.luminance + lightBand)) - - context!.beginPath() - context!.arc( - baseX + displacement * 0.18, - baseY + displacement, - dotRadius + Math.max(0, lightBand) * 1.35, - 0, - Math.PI * 2, - ) - context!.fillStyle = point.accent - ? `rgba(176, 119, 47, ${Math.min(0.76, opacity + 0.12)})` - : `rgba(38, 63, 45, ${opacity * 0.38})` - context!.fill() - } - } - - function resize() { - const bounds = canvas!.getBoundingClientRect() - const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR) - width = Math.max(1, bounds.width) - height = Math.max(1, bounds.height) - canvas!.width = Math.round(width * dpr) - canvas!.height = Math.round(height * dpr) - context!.setTransform(dpr, 0, 0, dpr, 0, 0) - draw() - } - - function animate(time: number) { - draw(time) - animationFrame = window.requestAnimationFrame(animate) - } - - function load() { - points = sampleMark(image) - draw() - if (!reduceMotion) animationFrame = window.requestAnimationFrame(animate) - } - - const observer = new ResizeObserver(resize) - observer.observe(canvas) - image.addEventListener('load', load, { once: true }) - resize() - - return () => { - window.cancelAnimationFrame(animationFrame) - observer.disconnect() - image.removeEventListener('load', load) - } - }, []) - - return ( -