From 1adacea3eb4da4746fcc87db7f746e1fc532869c Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:03 +0800 Subject: [PATCH 01/49] build(frontend): scaffold vite react typescript workspace The MS2 frontend needs a reproducible workspace before any module code can land. Add the Vite entry, the pinned dependency manifest and lockfile, the three referenced TypeScript configs, and the ignore rules. The workspace installs and builds from a clean checkout. --- .gitignore | 4 + frontend/.gitignore | 28 + frontend/index.html | 12 + frontend/package-lock.json | 3630 +++++++++++++++++++++++++++++++++++ frontend/package.json | 36 + frontend/tsconfig.app.json | 31 + frontend/tsconfig.json | 4 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 16 + 9 files changed, 3784 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2603d1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# macOS +.DS_Store + +# 依赖与构建产物由各子项目的 .gitignore 负责 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..00baad0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Vercel 本地项目链接元数据,不提交账号和项目 ID +.vercel + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env* diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..cd1df0c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Windup · 2D 角色资产生成 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d513d30 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3630 @@ +{ + "name": "windup-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "windup-frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "oxfmt": "^0.61.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.61.0.tgz", + "integrity": "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.61.0.tgz", + "integrity": "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.61.0.tgz", + "integrity": "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.61.0.tgz", + "integrity": "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.61.0.tgz", + "integrity": "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.61.0.tgz", + "integrity": "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.61.0.tgz", + "integrity": "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.61.0.tgz", + "integrity": "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.61.0.tgz", + "integrity": "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.61.0.tgz", + "integrity": "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.61.0.tgz", + "integrity": "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.61.0.tgz", + "integrity": "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.61.0.tgz", + "integrity": "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.61.0.tgz", + "integrity": "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.61.0.tgz", + "integrity": "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.61.0.tgz", + "integrity": "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.61.0.tgz", + "integrity": "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.61.0.tgz", + "integrity": "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.61.0.tgz", + "integrity": "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz", + "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz", + "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz", + "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz", + "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz", + "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz", + "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz", + "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz", + "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz", + "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz", + "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz", + "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz", + "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz", + "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz", + "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz", + "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz", + "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz", + "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz", + "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz", + "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "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" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "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 + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "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 + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "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.3", + "@exodus/bytes": "^1.15.0", + "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.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.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" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxfmt": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.61.0.tgz", + "integrity": "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "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" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", + "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.75.0", + "@oxlint/binding-android-arm64": "1.75.0", + "@oxlint/binding-darwin-arm64": "1.75.0", + "@oxlint/binding-darwin-x64": "1.75.0", + "@oxlint/binding-freebsd-x64": "1.75.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.75.0", + "@oxlint/binding-linux-arm-musleabihf": "1.75.0", + "@oxlint/binding-linux-arm64-gnu": "1.75.0", + "@oxlint/binding-linux-arm64-musl": "1.75.0", + "@oxlint/binding-linux-ppc64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-musl": "1.75.0", + "@oxlint/binding-linux-s390x-gnu": "1.75.0", + "@oxlint/binding-linux-x64-gnu": "1.75.0", + "@oxlint/binding-linux-x64-musl": "1.75.0", + "@oxlint/binding-openharmony-arm64": "1.75.0", + "@oxlint/binding-win32-arm64-msvc": "1.75.0", + "@oxlint/binding-win32-ia32-msvc": "1.75.0", + "@oxlint/binding-win32-x64-msvc": "1.75.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "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" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0a845f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "windup-frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check", + "preview": "vite preview", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "oxfmt": "^0.61.0", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..474b9e6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* 路径别名:@/xxx -> src/xxx,需与 vite.config.ts 的 resolve.alias 保持一致 */ + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b928223 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath, URL } from 'node:url' + +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + // 全项目统一用 @/xxx 引用 src 下的模块,避免 ../../.. 相对路径 + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}) From 059d3759313875c725418bc6733d26bc5198092f Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:03 +0800 Subject: [PATCH 02/49] build(frontend): add oxlint and oxfmt configuration Style and lint rules should be fixed before contributors start writing modules. Add the oxlint rule set and the oxfmt formatting profile for the frontend workspace. Formatting and lint results are identical on every machine and in CI. --- frontend/.oxfmtrc.json | 9 +++++++++ frontend/.oxlintrc.json | 8 ++++++++ 2 files changed, 17 insertions(+) create mode 100644 frontend/.oxfmtrc.json create mode 100644 frontend/.oxlintrc.json diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json new file mode 100644 index 0000000..293ef6a --- /dev/null +++ b/frontend/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "endOfLine": "lf", + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "ignorePatterns": ["**/*.md"] +} diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} From 980d5112407a036ff860f00b000970877c50fdd2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:03 +0800 Subject: [PATCH 03/49] build(frontend): add vitest configuration The skeleton carries no test suite yet, but the test runner has to be wired for later work. Add the vitest config so it resolves the same path aliases as the application build. Tests can be added to any module without further setup. --- frontend/vitest.config.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 frontend/vitest.config.ts diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..3a40db6 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + // 纯逻辑测试跑 node;需要 DOM 的用文件顶部 @vitest-environment jsdom 单独声明 + environment: 'node', + include: [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + '../tests/**/*.test.ts', + '../tests/**/*.test.tsx', + ], + }, +}) From fd4424a5bcfbdd727f9d620b6dde5c9c9966f858 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:24 +0800 Subject: [PATCH 04/49] feat(shared): add shared layer with pagination contract Every upper layer needs a place for cross-module primitives that belong to no single domain. Add the shared layer with its pagination contract and a README stating what may live here. Modules have one agreed home for shared primitives instead of duplicating them. --- frontend/src/shared/README.md | 28 +++++++++++++++++++++++++ frontend/src/shared/pagination/index.ts | 13 ++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 frontend/src/shared/README.md create mode 100644 frontend/src/shared/pagination/index.ts diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md new file mode 100644 index 0000000..b35fb60 --- /dev/null +++ b/frontend/src/shared/README.md @@ -0,0 +1,28 @@ +# shared + +与 Windup 业务无关、可被任意上层模块复用的基础代码。它是依赖方向的最底层,**不能反向依赖 `entities`、`features`、`pages` 或 `app`**。 + +## 判断标准 + +**如果一段代码需要理解 Windup 的业务词汇,它就不属于 shared。** + +## 现有内容 + +- `pagination/` —— 与传输协议无关的分页请求与结果形状。 + +## 后续允许放入 + +- `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件 +- `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键 +- `utils/` —— 纯函数工具,例如日期格式化、文件大小显示 +- `config/` —— 前端通用常量与运行时配置读取 + +**这些目录只在出现真实代码时创建,不为占位提前建空文件。** + +## 不允许放入 + +- Project、Character、Generation、Task、WorkflowRun 等业务数据 +- `ProjectApis`、`CharacterApis` 这类业务接口集合 +- 流程的推进、重启、中断和 Revision 规则 +- 为开发与生产各维护一套实现的切换机制 +- 只被单个页面或模块使用、却以「复用」名义提前抽出的代码 diff --git a/frontend/src/shared/pagination/index.ts b/frontend/src/shared/pagination/index.ts new file mode 100644 index 0000000..d5e2c27 --- /dev/null +++ b/frontend/src/shared/pagination/index.ts @@ -0,0 +1,13 @@ +/** 与传输协议无关的分页请求形状。 */ +export interface PageQuery { + page?: number + pageSize?: number +} + +/** 与传输协议无关的分页结果形状。 */ +export interface Paged { + items: T[] + total: number + page: number + pageSize: number +} From 7e22b51e16ad9744ccbf6072907170147de5c24c Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:24 +0800 Subject: [PATCH 05/49] feat(entities): add data layer public interfaces The frontend has to state which business objects it owns and how each one is reached. Add one contract file per entity for character, project, task, generation, media, action template, playtest inspection and workflow run, plus the aggregate export. Upper layers depend on declared entity interfaces rather than on transport details. --- .../src/entities/action-template/index.ts | 14 ++ frontend/src/entities/character/index.ts | 138 ++++++++++++++++ frontend/src/entities/generation/index.ts | 108 ++++++++++++ frontend/src/entities/index.ts | 86 ++++++++++ frontend/src/entities/media/index.ts | 9 + .../src/entities/playtest-inspection/index.ts | 29 ++++ frontend/src/entities/project/index.ts | 78 +++++++++ frontend/src/entities/task/index.ts | 45 +++++ frontend/src/entities/workflow-run/index.ts | 155 ++++++++++++++++++ 9 files changed, 662 insertions(+) create mode 100644 frontend/src/entities/action-template/index.ts create mode 100644 frontend/src/entities/character/index.ts create mode 100644 frontend/src/entities/generation/index.ts create mode 100644 frontend/src/entities/index.ts create mode 100644 frontend/src/entities/media/index.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/entities/project/index.ts create mode 100644 frontend/src/entities/task/index.ts create mode 100644 frontend/src/entities/workflow-run/index.ts diff --git a/frontend/src/entities/action-template/index.ts b/frontend/src/entities/action-template/index.ts new file mode 100644 index 0000000..2dafa39 --- /dev/null +++ b/frontend/src/entities/action-template/index.ts @@ -0,0 +1,14 @@ +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/index.ts b/frontend/src/entities/character/index.ts new file mode 100644 index 0000000..db8315b --- /dev/null +++ b/frontend/src/entities/character/index.ts @@ -0,0 +1,138 @@ +/** + * 动作「如何被定义」的来源维度: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' + +/** 动作在生成流水线上的位置。 */ +export type ActionStatus = + /** 已加入工作流但还没开始生成 */ + | 'planned' + /** 后端任务进行中 */ + | 'generating' + /** 生成完成,是候选,还没被用户确认 */ + | 'candidate' + /** 用户确认后成为正式资产 */ + | 'confirmed' + /** 生成失败 */ + | 'failed' + +/** 系统质检结论。系统通过不等于人工通过,分开记。 */ +export type FrameQcResult = 'pending' | 'passed' | 'failed' + +/** 单帧相对动作首帧的根位移,单位为像素。 */ +export interface FrameRootMotion { + dx: number + /** 正值表示向上。 */ + dy: number +} + +/** 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 */ +export interface Frame { + imageUrl: string + /** + * 此帧的显示时长,单位毫秒。 + * null 表示该帧没有独立时长,读取方才使用所属 Action.fps 计算等时长回退值。 + */ + durationMs: number | null + /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ + rootMotion: FrameRootMotion | null + /** 后端自动质检结论,与人工 rejected 状态相互独立。 */ + qc: FrameQcResult + /** 人工退回。不设「通过此帧」,故是布尔量而非三态。 */ + rejected: boolean +} + +/** 一张母版候选;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 { + id: string + outfitId: Outfit['id'] + name: string + /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ + kind: ActionKind + /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ + type: ActionType + status: ActionStatus + /** + * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; + * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 + */ + fps: number + /** + * 攻击触点、跳跃顶点等关键时刻在 frames 中的零基下标;null 表示没有明确关键帧。 + * 非 null 值必须指向当前 frames 数组内的成员。 + */ + keyFrameIndex: number | null + /** 按播放顺序排列的帧;数组下标就是零基帧序号。 */ + frames: Frame[] +} + +/** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ +export interface Outfit { + id: string + characterId: string + name: string + /** 母版生成阶段返回的候选;生成完成前可以为空数组。 */ + candidateCharacterTemplates: CharacterTemplateCandidate[] + /** 用户从候选图中选定的角色母版 URL;尚未选定时为 null。 */ + characterTemplateUrl: string | null + /** 供后续动作生成使用的只读基础帧入口。 */ + readonly baseFrames: readonly BaseFrame[] + /** 每个 Action.outfitId 必须等于本造型 ID。 */ + actions: Action[] +} + +/** 项目下的角色资产;造型拥有各自的母版和动作帧。 */ +export interface Character { + id: string + projectId: string + name: string + /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ + outfits: Outfit[] + createdAt: string + updatedAt: string +} + +/** 创建角色并发起母版生成所需的入参。 */ +export interface CreateCharacterInput { + projectId: string + name: string + /** 交给模型生成母版。 */ + description: string + referenceImageUrl?: string | null +} + +/** + * Character 对应的一组后端接口。 + * 造型、动作和帧是 Character 内的完整树,不通过独立粒度方法写入;每次确认后整棵更新。 + */ +export interface CharacterApis { + get(id: Character['id']): Promise + listByProject(projectId: string): Promise + create(input: CreateCharacterInput): Promise + update(character: Character): Promise +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts new file mode 100644 index 0000000..7c932fc --- /dev/null +++ b/frontend/src/entities/generation/index.ts @@ -0,0 +1,108 @@ +import type { ActionType } from '../character' +import type { MediaReference } from '../media' +import type { TaskStatus } from '../task' + +/** + * Generation 是业务数据,不是「调用图片生成能力」。 + * 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。 + */ + +/** 生成对应的三个前端可见异步步骤。 */ +export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' + +interface GenerationInputBase { + projectId: string + /** 可选参考媒体;没有参考图时传空数组。 */ + referenceMedia: readonly MediaReference[] +} + +/** 角色母版候选生成。 */ +export interface CharacterTemplateGenerationInput extends GenerationInputBase { + type: 'character_template' + /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ + prompt: string +} + +/** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ +export interface FirstFrameGenerationInput extends GenerationInputBase { + type: 'first_frame' + characterId: string + outfitId: string + actionType: ActionType + /** 自定义动作或额外动作要求;没有时为 null。 */ + prompt: string | null +} + +/** 以已确认首帧为起点生成完整动画。 */ +export interface CompleteAnimationGenerationInput extends GenerationInputBase { + type: 'complete_animation' + characterId: string + outfitId: string + actionType: ActionType + /** 已确认的生成首帧 URL。 */ + firstFrameUrl: string + prompt: string | null +} + +export type GenerationInput = + | CharacterTemplateGenerationInput + | FirstFrameGenerationInput + | CompleteAnimationGenerationInput + +/** 后端当前能交付给前端的最小图片结果。 */ +export interface GeneratedImage { + url: string +} + +/** 结果按 type 分别定义,不共用一个 urls 数组。 */ +export interface CharacterTemplateGenerationResult { + type: 'character_template' + images: readonly GeneratedImage[] +} + +export interface FirstFrameGenerationResult { + type: 'first_frame' + image: GeneratedImage +} + +/** 帧顺序由数组位置表达。 */ +export interface CompleteAnimationGenerationResult { + type: 'complete_animation' + frames: readonly GeneratedImage[] +} + +export type GenerationResult = + | CharacterTemplateGenerationResult + | FirstFrameGenerationResult + | CompleteAnimationGenerationResult + +export type GenerationResultFor = + T extends CharacterTemplateGenerationInput + ? CharacterTemplateGenerationResult + : T extends FirstFrameGenerationInput + ? FirstFrameGenerationResult + : CompleteAnimationGenerationResult + +/** + * 一次生成任务的完整快照。 + * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 + */ +export interface Generation { + id: string + projectId: string + /** 与创建时的输入判别字段保持同一字面量类型。 */ + type: TType + status: TaskStatus + /** 完成前为 null;完成后形状由 type 决定。 */ + result: GenerationResult | null + /** status 为 failed 时有值。 */ + error: string | null +} + +/** Generation 对应的一组后端接口。 */ +export interface GenerationApis { + /** 创建一次生成任务。 */ + create(input: T): Promise> + /** 读取生成任务的最新快照。 */ + get(id: Generation['id']): Promise +} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts new file mode 100644 index 0000000..804de8b --- /dev/null +++ b/frontend/src/entities/index.ts @@ -0,0 +1,86 @@ +/** + * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 + * 本次只提交类型与接口,不提交实现。 + */ + +/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export type { + CharacterPerspective, + CreateProjectInput, + DirectionalMovement, + Project, + ProjectApis, + UpdateProjectInput, +} from './project' + +/* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ +export type { + Action, + ActionKind, + ActionStatus, + ActionType, + BaseFrame, + Character, + CharacterApis, + CharacterTemplateCandidate, + ConfirmCharacterTemplateInput, + CreateCharacterInput, + Frame, + FrameQcResult, + FrameRootMotion, + Outfit, +} from './character' + +/* 动作模板 —— 能跨角色复用的配方 */ +export type { ActionTemplate, ActionTemplateApis } from './action-template' + +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export type { + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + FirstFrameGenerationInput, + FirstFrameGenerationResult, + GeneratedImage, + Generation, + GenerationApis, + GenerationInput, + GenerationResult, + GenerationResultFor, + GenerationType, +} from './generation' + +/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ +export type { MediaReference } from './media' + +/* 后端异步任务 —— 与工作流节点是两回事 */ +export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' + +/* 工作流 —— 节点由前端推进,运行记录由后端持久化 */ +export { WORKFLOW_STEP_ORDER } from './workflow-run' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunApis, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowTaskLink, +} from './workflow-run' + +/* Playtest 核验记录 —— 独立于 Character 与 WorkflowRun */ +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + RecordPlaytestInspectionInput, +} from './playtest-inspection' diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts new file mode 100644 index 0000000..347e626 --- /dev/null +++ b/frontend/src/entities/media/index.ts @@ -0,0 +1,9 @@ +declare const mediaReferenceBrand: unique symbol + +/** + * 已上传媒体的不透明引用。 + * 当前不承诺运行时字符串代表 URL、media_id 或其他后端标识。 + */ +export type MediaReference = string & { + readonly [mediaReferenceBrand]: 'MediaReference' +} diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts new file mode 100644 index 0000000..e984111 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/index.ts @@ -0,0 +1,29 @@ +/** 可持久化的 Playtest 核验结论;没有记录即表示尚未核验。 */ +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +/** 一次独立核验记录,不属于 Character 或 WorkflowRevision 的内部字段。 */ +export interface PlaytestInspection { + id: string + characterId: string + outfitId: string + /** 仅从 Workflow 进入时保留返回定位;独立入口为 null。 */ + source: { runId: string; revisionId: string } | null + status: PlaytestInspectionStatus + createdAt: string + updatedAt: string +} + +/** 创建或更新指定核验目标结论所需的业务输入。 */ +export interface RecordPlaytestInspectionInput { + characterId: string + outfitId: string + /** 可选的 Workflow 来源,不是播放和核验的前置条件。 */ + source?: { runId: string; revisionId: string } | null + status: PlaytestInspectionStatus +} + +/** PlaytestInspection 对应的一组后端接口。 */ +export interface PlaytestInspectionApis { + getLatest(target: { characterId: string; outfitId: string }): Promise + record(input: RecordPlaytestInspectionInput): Promise +} diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts new file mode 100644 index 0000000..778acf8 --- /dev/null +++ b/frontend/src/entities/project/index.ts @@ -0,0 +1,78 @@ +import type { Paged, PageQuery } from '@/shared/pagination' + +/** Project 前端领域形状;字段只表达当前页面需要,不对应任何已确认后端 DTO。 */ +export interface Project { + id: string + /** Project 所属用户 ID;认证来源尚未冻结。 */ + ownerId: string + 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 +} + +/** 新建项目的入参。 */ +export interface CreateProjectInput { + name: string + perspective: CharacterPerspective + directionalMovement: DirectionalMovement + spriteSize: { width: number; height: number } + gameStyle?: string | null + sampleImageUrl?: string | null +} + +/** 更新项目设置的入参;未提供的字段保持不变。 */ +export interface UpdateProjectInput { + name?: string + perspective?: CharacterPerspective + directionalMovement?: DirectionalMovement + spriteSize?: { width: number; height: number } + gameStyle?: string | null + sampleImageUrl?: string | null +} + +/** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ +export type CharacterPerspective = 'side' | 'top-down' | 'isometric' + +/** 前端使用的移动方向枚举;后端映射尚未冻结。 */ +export type DirectionalMovement = 'single' | 'four-way' | 'eight-way' + +/** 游戏视角的页面文案。 */ +export const CHARACTER_PERSPECTIVE: Record = { + side: '横版视角', + 'top-down': '俯视', + isometric: '2.5D', +} + +/** 移动方向,决定一个动作要生成几套朝向的帧。 */ +export const DIRECTIONAL_MOVEMENT: Record = { + single: '单向', + 'four-way': '四向', + 'eight-way': '八向', +} + +/** UI 使用的建议尺寸档位;不代表后端约束。 */ +export const SPRITE_SIZES = [32, 64, 128, 256, 512, 1024, 2048] as const + +/** Project 对应的一组后端接口。 */ +export interface ProjectApis { + list(query?: PageQuery): Promise> + get(id: Project['id']): Promise + create(input: CreateProjectInput): Promise + update(id: Project['id'], input: UpdateProjectInput): Promise + remove(id: Project['id']): Promise +} diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts new file mode 100644 index 0000000..eaccba1 --- /dev/null +++ b/frontend/src/entities/task/index.ts @@ -0,0 +1,45 @@ +/** + * 异步任务实体,与 WorkflowRun 的页面节点是两回事。 + * + * 任务粒度 = 前端可见的一个异步步骤,而不是内部某次模型调用。 + * 任务生命周期不等于工作流节点状态,WorkflowRun 只通过 WorkflowTaskLink 记录两者关系。 + */ + +/** 后端异步任务状态;pending 表示已提交但尚未执行。 */ +export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' + +/** + * MS2 前端需要展示和恢复的三类异步步骤。 + * 完整动画内部可包含视频生成、截帧和多次图像处理,但对前端仍是一个 Task。 + */ +export type TaskType = 'character_template' | 'first_frame' | 'complete_animation' + +/** + * 创建、查询和断线恢复都使用的完整任务快照。 + * TType 在调用边界已知时保留精确任务类型;按 ID 恢复时使用默认值,等待运行时解析后再收窄。 + */ +export interface Task { + /** 后端生成的任务 ID,是查询状态和订阅事件的唯一标识。 */ + id: string + /** 后端 task_type 对应的领域判别字段,不接受任意字符串。 */ + type: TType + /** 后端任务当前状态;它不会直接改变工作流节点状态。 */ + status: TaskStatus + /** 失败原因,status 为 failed 时有值。completed 不表示工作流节点已通过。 */ + error: string | null + /** 任务产出;契约冻结前保持 unknown,避免调用方依赖猜测结构。 */ + result: unknown +} + +/** 每条事件携带同类型 Task 的完整状态,taskId 对应 Task.id。 */ +export interface TaskEvent extends Omit, 'id'> { + taskId: Task['id'] +} + +/** Task 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */ +export interface TaskApis { + /** 按后端 Task ID 读取最新快照;实现必须在运行时校验 task_type 后才返回。 */ + get(taskId: Task['id']): Promise + /** 订阅任务状态变化,返回取消订阅函数。 */ + subscribe(taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void +} diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts new file mode 100644 index 0000000..04ec0e2 --- /dev/null +++ b/frontend/src/entities/workflow-run/index.ts @@ -0,0 +1,155 @@ +import type { Task } from '../task' + +/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ +export type WorkflowDriver = 'ai' | 'manual' + +/** 创建 WorkflowRun 时要完成的用户意图。 */ +export type WorkflowRunPurpose = 'create_character' | 'add_action' + +/** + * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 + */ +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] + +/** + * 步骤的可用性和执行结果;不直接复用后端任务状态。 + * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 + */ +export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' + +/** + * 单个版本的生命周期。 + * abandoned 表示停止沿用但仍保留为历史。 + */ +export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' + +/** + * 整次流程的汇总状态。 + * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 + * 后端 Task 是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + */ +export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' + +/** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' + +/** 当前版本在导出阶段的汇总状态。 */ +export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 + */ +export interface WorkflowStep { + /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ + id: string + type: WorkflowStepType + status: WorkflowStepStatus + /** 进入步骤时保存的输入快照。 */ + input: unknown + /** 步骤完成后的结果或引用;尚无结果时为 null。 */ + output: unknown + /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ + referenceStepIds: string[] +} + +/** 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 */ +export interface WorkflowRevision { + id: string + /** 首次创建的版本没有来源,因此为 null。 */ + basedOnRevisionId: string | null + /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + /** + * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 + * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + */ + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 一次由前端推进的页面流程。 + * 步骤怎么走由前端决定;WorkflowRun 本身由后端持久化,前端通过 WorkflowRunApis 读写。 + */ +export interface WorkflowRun { + id: string + projectId: string + /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ + characterId: string | null + /** 已有角色加动作时的目标造型;新建角色时为 null。 */ + outfitId: string | null + purpose: WorkflowRunPurpose + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ + currentRevisionId: string + /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ + revisions: WorkflowRevision[] + /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ + prompt: string | null +} + +/** 两种入口共享的创建字段。 */ +interface CreateWorkflowRunInputBase { + projectId: string + driver: WorkflowDriver + /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ + prompt?: string +} + +/** + * 创建 WorkflowRun 的输入。 + * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + characterTemplateUrl?: never + baseFrameUrls?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + characterTemplateUrl: string + baseFrameUrls: readonly string[] + } + ) + +/** 前端编排关联;后端 Task 不需要认识 WorkflowRun、Revision 或流程步骤。 */ +export interface WorkflowTaskLink { + taskId: Task['id'] + runId: WorkflowRun['id'] + /** 发起任务时所在的版本 ID,避免结果写入后来创建的新版本。 */ + revisionId: WorkflowRevision['id'] + /** 发起任务的步骤 ID,用于把结果映射回正确页面阶段。 */ + stepId: WorkflowStep['id'] +} + +/** WorkflowRun 对应的一组后端接口;只负责存取,不负责推进。 */ +export interface WorkflowRunApis { + get(runId: WorkflowRun['id']): Promise + create(input: CreateWorkflowRunInput): Promise + /** 保存已由 WorkflowController 完成合法状态转换的流程快照。 */ + save(run: WorkflowRun): Promise +} From 73d7b981c3011eafe8947e388146536ba9053686 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:24 +0800 Subject: [PATCH 06/49] feat(features): add use case layer contracts The workflow steps the mentor reviewed need an explicit home separate from both entities and pages. Add contracts for character setup, generation, review, export and the workflow controller. Each use case has a named boundary that pages call instead of reaching into entities. --- .../src/features/character-setup/index.ts | 7 ++ frontend/src/features/export/index.ts | 6 ++ frontend/src/features/generation/index.ts | 6 ++ frontend/src/features/review/index.ts | 7 ++ .../src/features/workflow-controller/index.ts | 67 +++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 frontend/src/features/character-setup/index.ts create mode 100644 frontend/src/features/export/index.ts create mode 100644 frontend/src/features/generation/index.ts create mode 100644 frontend/src/features/review/index.ts create mode 100644 frontend/src/features/workflow-controller/index.ts diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts new file mode 100644 index 0000000..44c9a7b --- /dev/null +++ b/frontend/src/features/character-setup/index.ts @@ -0,0 +1,7 @@ +import type { CreateCharacterInput } from '@/entities' + +/** 填写角色资料并提交母版生成。 */ +export interface CharacterSetupProps { + projectId: string + onSubmit(input: CreateCharacterInput): void +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts new file mode 100644 index 0000000..adfc5aa --- /dev/null +++ b/frontend/src/features/export/index.ts @@ -0,0 +1,6 @@ +/** 把确认后的造型与动作导出到资产库。 */ +export interface ExportProps { + runId: string + characterId: string + outfitId: string +} diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts new file mode 100644 index 0000000..47f5997 --- /dev/null +++ b/frontend/src/features/generation/index.ts @@ -0,0 +1,6 @@ +/** 触发并展示一次生成;不感知后端调用的是哪个模型。 */ +export interface GenerationProps { + runId: string + /** 生成母版等非动作任务可以省略。 */ + actionId?: string +} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts new file mode 100644 index 0000000..8a897e8 --- /dev/null +++ b/frontend/src/features/review/index.ts @@ -0,0 +1,7 @@ +/** 逐帧审核:打回有问题的帧。不设「通过此帧」,默认通过。 */ +export interface ReviewProps { + actionId: string + frameIndex: number + onSelectFrame(index: number): void + onRejectFrame(index: number): void +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts new file mode 100644 index 0000000..3ccff25 --- /dev/null +++ b/frontend/src/features/workflow-controller/index.ts @@ -0,0 +1,67 @@ +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 提供推进、更新、重启和中断。这些操作依赖同一份 + * 步骤数据,不拆成互不共享状态的独立模块。 + * + * 步骤怎么走由前端决定,服务端只负责持久化 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 +} From 6b21545783682ec3dcbcc6274129c261a8aadb7c Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 07/49] feat(pages): add page modules Routing targets have to exist before the application shell can wire them together. Add the home, projects, project detail, quick start, workflow editor, asset library, playtest and not found page modules. Every route in the product design maps to exactly one page module. --- frontend/src/pages/asset-library/index.tsx | 9 +++++++++ frontend/src/pages/home/index.tsx | 13 +++++++++++++ frontend/src/pages/not-found/index.tsx | 9 +++++++++ frontend/src/pages/playtest/index.tsx | 9 +++++++++ frontend/src/pages/project-detail/index.tsx | 9 +++++++++ frontend/src/pages/projects/index.tsx | 9 +++++++++ frontend/src/pages/quick-start/index.tsx | 9 +++++++++ frontend/src/pages/workflow-editor/index.tsx | 9 +++++++++ 8 files changed, 76 insertions(+) create mode 100644 frontend/src/pages/asset-library/index.tsx create mode 100644 frontend/src/pages/home/index.tsx create mode 100644 frontend/src/pages/not-found/index.tsx create mode 100644 frontend/src/pages/playtest/index.tsx create mode 100644 frontend/src/pages/project-detail/index.tsx create mode 100644 frontend/src/pages/projects/index.tsx create mode 100644 frontend/src/pages/quick-start/index.tsx create mode 100644 frontend/src/pages/workflow-editor/index.tsx diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx new file mode 100644 index 0000000..016328b --- /dev/null +++ b/frontend/src/pages/asset-library/index.tsx @@ -0,0 +1,9 @@ +/** 资产库。 */ +export function AssetLibraryPage() { + return ( +
+

资产库

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx new file mode 100644 index 0000000..7a153ad --- /dev/null +++ b/frontend/src/pages/home/index.tsx @@ -0,0 +1,13 @@ +/** 首页:入口与项目概览。 */ +export function HomePage() { + return +} + +function PagePlaceholder({ title }: { title: string }) { + return ( +
+

{title}

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/not-found/index.tsx b/frontend/src/pages/not-found/index.tsx new file mode 100644 index 0000000..d35181b --- /dev/null +++ b/frontend/src/pages/not-found/index.tsx @@ -0,0 +1,9 @@ +/** 页面不存在。 */ +export function NotFoundPage() { + return ( +
+

页面不存在

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx new file mode 100644 index 0000000..9796e86 --- /dev/null +++ b/frontend/src/pages/playtest/index.tsx @@ -0,0 +1,9 @@ +/** 核验台。 */ +export function PlaytestPage() { + return ( +
+

核验台

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx new file mode 100644 index 0000000..cc6ff5c --- /dev/null +++ b/frontend/src/pages/project-detail/index.tsx @@ -0,0 +1,9 @@ +/** 项目详情。 */ +export function ProjectDetailPage() { + return ( +
+

项目详情

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx new file mode 100644 index 0000000..8f807f7 --- /dev/null +++ b/frontend/src/pages/projects/index.tsx @@ -0,0 +1,9 @@ +/** 项目列表。 */ +export function ProjectsPage() { + return ( +
+

项目列表

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx new file mode 100644 index 0000000..1ffe9fe --- /dev/null +++ b/frontend/src/pages/quick-start/index.tsx @@ -0,0 +1,9 @@ +/** 快速开始。 */ +export function QuickStartPage() { + return ( +
+

快速开始

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx new file mode 100644 index 0000000..59df779 --- /dev/null +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -0,0 +1,9 @@ +/** 工作流画布。 */ +export function WorkflowEditorPage() { + return ( +
+

工作流画布

+

本次只提交模块划分与接口,页面实现进后续 PR。

+
+ ) +} From e5de44093ada07325cc040ab5013234e61a7bb40 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 08/49] feat(app): add application shell and entry point The page modules need a composition root that owns routing and layout. Add the app shell with its layout and route table, the browser entry point, and the base stylesheet. The application runs end to end and every page is reachable. --- frontend/src/app/app.tsx | 36 +++++++++++++++++++++++++++++++ frontend/src/app/index.ts | 1 + frontend/src/app/layout/index.tsx | 32 +++++++++++++++++++++++++++ frontend/src/index.css | 8 +++++++ frontend/src/main.tsx | 11 ++++++++++ 5 files changed, 88 insertions(+) create mode 100644 frontend/src/app/app.tsx create mode 100644 frontend/src/app/index.ts create mode 100644 frontend/src/app/layout/index.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx new file mode 100644 index 0000000..b46ae87 --- /dev/null +++ b/frontend/src/app/app.tsx @@ -0,0 +1,36 @@ +import { BrowserRouter, Route, Routes } from 'react-router' + +import { AssetLibraryPage } from '@/pages/asset-library' +import { HomePage } from '@/pages/home' +import { NotFoundPage } from '@/pages/not-found' +import { PlaytestPage } from '@/pages/playtest' +import { ProjectDetailPage } from '@/pages/project-detail' +import { ProjectsPage } from '@/pages/projects' +import { QuickStartPage } from '@/pages/quick-start' +import { WorkflowEditorPage } from '@/pages/workflow-editor' +import { AppShell } from './layout' + +/** + * 路由表与全局外壳。 + * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 + */ +export function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/frontend/src/app/index.ts b/frontend/src/app/index.ts new file mode 100644 index 0000000..f1c81c7 --- /dev/null +++ b/frontend/src/app/index.ts @@ -0,0 +1 @@ +export { App } from './app' diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx new file mode 100644 index 0000000..c69669a --- /dev/null +++ b/frontend/src/app/layout/index.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' +import { Link } from 'react-router' + +/** + * 模块五:跨页面常驻导航。不属于任何单个页面,故上提到 app 层。 + * 页内头部 PageHeader 在 shared/ui —— pages 不能 import app 层。 + */ + +export interface AppShellProps { + /** 渲染在全局导航下方的当前路由页面。 */ + children: ReactNode +} + +/** 全站外壳,全局导航常驻。 */ +export function AppShell({ children }: AppShellProps) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f80172a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +@import 'tailwindcss'; + +/* 全局只放这一点点:其余样式一律走 Tailwind 工具类,避免样式散落各处。 */ +html, +body, +#root { + height: 100%; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ff3b910 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from '@/app' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) From 0a13193592df74e89d4c4ffa16cae87f9c16bfb2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 09/49] ci(frontend): add quality gate and preview deployment Reviewers need the branch checked automatically and a running preview to look at. Add the frontend workflow running lint, typecheck, test and build, and scope Vercel previews to frontend changes. Every pull request reports its own build status and preview URL. --- .github/workflows/frontend-ci.yml | 56 +++++++++++++++++++++++++++++++ frontend/vercel.json | 13 +++++++ 2 files changed, 69 insertions(+) create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 frontend/vercel.json diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..fa48364 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,56 @@ +name: Frontend CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + frontend-checks: + name: Frontend checks + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: 'true' + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..c99f398 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} From 33b6fa32595bc096b8726a157713fe3b66694a47 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 15:33:53 +0800 Subject: [PATCH 10/49] docs(frontend): document architecture and backend contract The module boundaries and the interfaces expected from the backend must be reviewable without reading the code. Add the architecture document, the frontend README, and the API contract listing every endpoint the frontend calls. Backend and frontend can align on one written list of interfaces. --- frontend-architecture-v3.md | 101 ++++++++++++++++++++++++++++++++ frontend/API_CONTRACT.md | 114 ++++++++++++++++++++++++++++++++++++ frontend/README.md | 30 ++++++++++ 3 files changed, 245 insertions(+) create mode 100644 frontend-architecture-v3.md create mode 100644 frontend/API_CONTRACT.md create mode 100644 frontend/README.md diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md new file mode 100644 index 0000000..01b8926 --- /dev/null +++ b/frontend-architecture-v3.md @@ -0,0 +1,101 @@ +# Windup 前端架构 + +本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 + +--- + +## 1. 模块划分 + +业务模块都在 `src/entities/` 下: + +| 模块 | 职责 | +|---|---| +| `project` | 项目级全局约束:视角、朝向数、精灵尺寸、画风 | +| `character` | 角色资产。造型、动作、帧是它内部的一棵树 | +| `action-template` | 能跨角色复用的动作配方 | +| `generation` | 一次生成任务这份业务数据 | +| `media` | 已上传媒体的不透明引用 | +| `task` | 后端异步步骤的状态 | +| `workflow-run` | 制作流程的运行记录 | +| `playtest-inspection` | 核验结论,独立于角色与流程 | + +**模块判据:这个东西能不能被单独取到。** + +能单独取,说明它需要自己的一套取数逻辑,才值得一个模块;取不到的,它只是别人身上的一个字段。 + +按这条判据,`Outfit`、`Action`、`Frame` 没有独立模块——它们不能脱离 `Character` 被取到,所以是 `character` 内部的类型。`ActionTemplate` 有独立模块,因为它能被不同角色复用。 + +--- + +## 2. 层次 + +```text +pages -> features -> entities -> shared +``` + +| 层 | 内容 | +|---|---| +| `pages` | 八个路由页面 | +| `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` | +| `entities` | 上表业务模块 | +| `shared` | 无业务语义的形状,目前只有分页 | + +`app` 只做启动和路由,不构造服务、不向下注入。 + +### 依赖规则 + +1. 只能向下依赖,不允许反向。 +2. 同层模块之间不互相导入。要共用就往下沉。 +3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。 +4. `entities` 内部模块之间可以互相导入,对外仍是一个门。 + +--- + +## 3. 接口命名 + +每个模块暴露一组后端接口,统一叫 `XxxApis`: + +```text +ProjectApis CharacterApis ActionTemplateApis GenerationApis +TaskApis WorkflowRunApis PlaytestInspectionApis +``` + +**不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 + +--- + +## 4. 流程推进 + +`features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 + +Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 + +步骤顺序固定八步: + +```text +角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 +``` + +**步骤怎么走由前端决定。** `WorkflowRunApis` 只负责存取运行记录,不负责推进。 + +从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 + +快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 + +--- + +## 5. 本次不包含 + +- 任何实现代码(真实请求、假数据、组件内部逻辑) +- 测试文件 +- 图片上传模块(体量太小,本次不单独体现) +- 穿戴道具相关(产品侧未设计) +- 第三方登录 + +页面当前是占位外壳,只声明路由与模块边界。 + +--- + +## 6. 未与后端对齐的部分 + +明细见 `frontend/API_CONTRACT.md`。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md new file mode 100644 index 0000000..f5a3206 --- /dev/null +++ b/frontend/API_CONTRACT.md @@ -0,0 +1,114 @@ +# 前后端接口对齐清单 + +前端各模块的 `XxxApis` 与后端 PR #64(head `10dd958`)逐条比对结果。 + +后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 + +--- + +## 一、前端预期有、后端目前没有 + +**这几条需要后端明确做或不做;不做的前端删掉。** + +| 前端接口 | 后端情况 | +|---|---| +| `WorkflowRunApis`(`get` / `create` / `save`) | 没有 workflow 模块 | +| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | +| `PlaytestInspectionApis`(`getLatest` / `record`) | 没有对应模块 | +| 独立的 `task` 模块 | 后端 Task 不独立,是 `generation` 内的 `GenerationTask` | + +前端已按服务端现状去掉 `TaskApis.cancel`——`GenerationService` 没有取消能力,不声明前端用不到的接口。 + +--- + +## 二、形状不一致 + +### 生成步骤:后端一步,前端两步 + +| 后端 `GenerationType` | 前端 `GenerationType` | +|---|---| +| `character_image` | `character_template` | +| `character_action` | `first_frame` → `complete_animation` | + +前端设计是先出动作首帧、用户确认后再生成完整动画。后端 `generate_character_action` 是一次到底。 + +**这不是命名差异,是交互差异。需要确认首帧确认这一步保不保。** + +### 其他 + +| 项 | 后端 | 前端 | +|---|---|---| +| 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | +| 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | +| 查任务 | `get_task(project_id, task_id)` 需要 `project_id` | `get(taskId)` 只传 taskId | +| 动作类型 | `walk` `idle` `attack` `custom` | 多一个 `jump` | +| 删除项目 | 返回 `bool`(是否找到) | 返回 `void` | + +ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不需要后端改动。 + +--- + +## 三、后端有、前端没接 + +| 后端 | 说明 | +|---|---| +| `project_name_exists(user_id, project_name)` | 建项目时的重名校验,前端未接 | +| `delete_character` | 前端 `CharacterApis` 没有删除 | +| `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | +| `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | +| `MediaService.upload` | 前端本次未提交上传模块 | + +--- + +## 四、审核数据无处保存 + +后端 `character_data` 的嵌套结构(见 `character/model.py`): + +```text +outfits[] → id / name / preview_url / actions[] +actions[] → id / type / name / loop / fps / frame_count / frames[] +frames[] → index / image_url / duration_ms +``` + +前端以下字段在后端结构里没有落点: + +- `Frame.qc`(系统质检结论) +- `Frame.rejected`(人工打回) +- `Action.kind`(preset / custom 来源) +- `Action.status`(planned / generating / candidate / confirmed / failed) +- `Action.keyFrameIndex` +- `Frame.rootMotion` +- `Outfit.candidateCharacterTemplates`(母版候选列表) +- `Outfit.baseFrames` + +**其中 `qc` 与 `rejected` 是审核台的全部依据。后端不存这两个字段,审核结果就落不了库。** + +`candidateCharacterTemplates` 同理:前端设计是生成多张候选让用户选,后端 `CharacterImageInput.num_images` 默认 1,`character_data` 里也没有候选列表。 + +--- + +## 五、概念不一致 + +后端 `character/model.py` 字段说明: + +> `reference_image_url`: 角色参考图,即旧概念中的 Character Template + +前端把这两者当成不同的东西: + +- 用户上传的参考图 —— 创建角色时的输入 +- AI 生成后用户选定的角色图(母版)—— `Outfit.characterTemplateUrl` + +**后端合成了一个字段。** 07-30 评审也提到「模板」这个叫法容易与 action template 混淆,暂改称「角色图」。三方对这里是几个概念的理解需要统一。 + +--- + +## 待确认 + +- [ ] 第一节四条:后端做还是不做 +- [ ] 动作生成一步还是两步 +- [ ] `Frame.qc` / `Frame.rejected` 落不落库 +- [ ] 母版候选几张 +- [ ] 参考图与角色图是一个字段还是两个 +- [ ] `Character.description` 前端要不要跟着存 +- [ ] `jump` 动作类型后端加不加 +- [ ] 上传模块何时提交 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..6e01fa3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# Windup 前端 + +React + Vite + TypeScript。 + +## 开发 + +```bash +npm ci +npm run dev +``` + +## 检查 + +```bash +npm run format:check # 格式 +npm run lint # 静态检查 +npm run typecheck # 类型 +npm run test # 测试(本阶段无测试文件) +npm run build # 构建 +``` + +CI 按上面顺序全跑一遍。 + +## 结构 + +模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 + +**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 + +与后端尚未对齐的接口见 `API_CONTRACT.md`。 From 01b0e0a1ffca078486a13850f4b3ae9a44fad4a2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:10:30 +0800 Subject: [PATCH 11/49] fix(workflow): persist the in-flight task id on the step A run reopened after a refresh could not tell that a generation was still running, so the page had to either resubmit it or stall. Record the in-flight task id on WorkflowStep so it is saved with the run, and drop WorkflowTaskLink, which no structure ever held. Reopening a run recovers the pending task from its step instead of starting a second generation. --- frontend/src/entities/index.ts | 1 - frontend/src/entities/task/index.ts | 3 ++- frontend/src/entities/workflow-run/index.ts | 16 ++++++---------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 804de8b..113ddaa 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -74,7 +74,6 @@ export type { WorkflowRunApis, WorkflowRunPurpose, WorkflowRunStatus, - WorkflowTaskLink, } from './workflow-run' /* Playtest 核验记录 —— 独立于 Character 与 WorkflowRun */ diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts index eaccba1..bd64883 100644 --- a/frontend/src/entities/task/index.ts +++ b/frontend/src/entities/task/index.ts @@ -2,7 +2,8 @@ * 异步任务实体,与 WorkflowRun 的页面节点是两回事。 * * 任务粒度 = 前端可见的一个异步步骤,而不是内部某次模型调用。 - * 任务生命周期不等于工作流节点状态,WorkflowRun 只通过 WorkflowTaskLink 记录两者关系。 + * 任务生命周期不等于工作流节点状态,两者只由 WorkflowStep.taskId 单向关联: + * 步骤记得自己发起过哪个任务,任务不认识步骤。 */ /** 后端异步任务状态;pending 表示已提交但尚未执行。 */ diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 04ec0e2..c4a715d 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -62,6 +62,12 @@ export interface WorkflowStep { input: unknown /** 步骤完成后的结果或引用;尚无结果时为 null。 */ output: unknown + /** + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 + * 它随 WorkflowRun 一起持久化,页面重开后据此查回在途任务的状态, + * 因而不会重复发起同一次生成。任务本身不认识步骤,反向关联不存在。 + */ + taskId: Task['id'] | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ referenceStepIds: string[] } @@ -136,16 +142,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & } ) -/** 前端编排关联;后端 Task 不需要认识 WorkflowRun、Revision 或流程步骤。 */ -export interface WorkflowTaskLink { - taskId: Task['id'] - runId: WorkflowRun['id'] - /** 发起任务时所在的版本 ID,避免结果写入后来创建的新版本。 */ - revisionId: WorkflowRevision['id'] - /** 发起任务的步骤 ID,用于把结果映射回正确页面阶段。 */ - stepId: WorkflowStep['id'] -} - /** WorkflowRun 对应的一组后端接口;只负责存取,不负责推进。 */ export interface WorkflowRunApis { get(runId: WorkflowRun['id']): Promise From 1f0ddcae55fd46b5b7b6635b8ded4fd768809809 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:27:26 +0800 Subject: [PATCH 12/49] build(frontend): typecheck the vitest config The vitest config sat outside every tsconfig include, so a type error in it would only surface when the runner started. Add it to the node project alongside the vite config. Both build-side configs are now covered by tsc -b. --- frontend/tsconfig.node.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8455dcb..d3daab6 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -19,5 +19,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } From 2be18e3ad535cef96acf91a459a7af57ded4cc1f Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:27:26 +0800 Subject: [PATCH 13/49] refactor(entities): keep only confirmed content in the asset tree A character is written when the user exports to the asset library, so it cannot hold work-in-progress state; the backend also returns no QC verdict and the product has no per-frame reject. Drop the action generation status, the frame qc and rejected fields, and the standalone playtest inspection entity; review now only reads frames. The asset tree describes confirmed assets only, and review results no longer need a second home. --- frontend/src/entities/character/index.ts | 49 ++++++++++--------- frontend/src/entities/index.ts | 10 ---- .../src/entities/playtest-inspection/index.ts | 29 ----------- frontend/src/features/review/index.ts | 9 +++- 4 files changed, 32 insertions(+), 65 deletions(-) delete mode 100644 frontend/src/entities/playtest-inspection/index.ts diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index db8315b..eecd8a1 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -10,22 +10,6 @@ export type ActionKind = 'preset' | 'custom' */ export type ActionType = 'walk' | 'idle' | 'attack' | 'jump' | 'custom' -/** 动作在生成流水线上的位置。 */ -export type ActionStatus = - /** 已加入工作流但还没开始生成 */ - | 'planned' - /** 后端任务进行中 */ - | 'generating' - /** 生成完成,是候选,还没被用户确认 */ - | 'candidate' - /** 用户确认后成为正式资产 */ - | 'confirmed' - /** 生成失败 */ - | 'failed' - -/** 系统质检结论。系统通过不等于人工通过,分开记。 */ -export type FrameQcResult = 'pending' | 'passed' | 'failed' - /** 单帧相对动作首帧的根位移,单位为像素。 */ export interface FrameRootMotion { dx: number @@ -33,7 +17,12 @@ export interface FrameRootMotion { dy: number } -/** 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 */ +/** + * 动作序列中的一张有序画面;帧序号由其在 Action.frames 中的位置决定。 + * + * 不带任何审核字段:服务端只交付生成好的帧,不返回质检结论;用户侧的审核也只是查看, + * 没有打回。前端若要做自动质检,那是读取帧之后在本地算出来的临时结论,不属于资产数据。 + */ export interface Frame { imageUrl: string /** @@ -43,10 +32,6 @@ export interface Frame { durationMs: number | null /** null 表示不提供根位移,Playtest 与 Export 不应据此施加任何位移。 */ rootMotion: FrameRootMotion | null - /** 后端自动质检结论,与人工 rejected 状态相互独立。 */ - qc: FrameQcResult - /** 人工退回。不设「通过此帧」,故是布尔量而非三态。 */ - rejected: boolean } /** 一张母版候选;attemptId 用于区分候选所属的生成尝试。 */ @@ -69,6 +54,10 @@ export interface BaseFrame { /** 某个角色造型下的一段动画动作。 */ export interface Action { + /** + * 仅在所属 Outfit 内唯一。动作没有自己的表,整棵树存在 character 记录里, + * 因此不存在全局唯一的动作 ID:任何按 ID 定位动作的地方都必须同时带上造型。 + */ id: string outfitId: Outfit['id'] name: string @@ -76,7 +65,6 @@ export interface Action { kind: ActionKind /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ type: ActionType - status: ActionStatus /** * 每秒播放帧数。仅当某帧 durationMs 为 null 时用于等时长回退; * Playtest 与 Export 不得用前端全局常量替代,也不得覆盖帧自己的 durationMs。 @@ -87,12 +75,20 @@ export interface Action { * 非 null 值必须指向当前 frames 数组内的成员。 */ keyFrameIndex: number | null - /** 按播放顺序排列的帧;数组下标就是零基帧序号。 */ + /** + * 按播放顺序排列的帧;数组下标就是零基帧序号。 + * 当前只表达单朝向。Project.directionalMovement 的四向/八向要如何落到这里 + * (本层再分组,还是一个朝向一条 Action)尚未有产品定义,不要凭猜先定结构。 + */ frames: Frame[] } /** 同一角色的一套独立造型;MVP UI 只展示第一套,但数据结构不折叠该层。 */ export interface Outfit { + /** + * 仅在所属 Character 内唯一。造型没有自己的表,与动作一起存在 character 记录里, + * 因此不存在全局唯一的造型 ID:任何按 ID 定位造型的地方都必须同时带上角色。 + */ id: string characterId: string name: string @@ -106,7 +102,12 @@ export interface Outfit { actions: Action[] } -/** 项目下的角色资产;造型拥有各自的母版和动作帧。 */ +/** + * 项目下的角色资产;造型拥有各自的母版和动作帧。 + * + * 这棵树只承载已导出到资产库的内容,因此其中的动作一律是已确认的,不带生成过程状态。 + * 工作流运行期间的造型、动作和帧活在 WorkflowRun 的步骤里,直到用户确认导出才整体写入。 + */ export interface Character { id: string projectId: string diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 113ddaa..6a4ce9c 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -18,7 +18,6 @@ export type { export type { Action, ActionKind, - ActionStatus, ActionType, BaseFrame, Character, @@ -27,7 +26,6 @@ export type { ConfirmCharacterTemplateInput, CreateCharacterInput, Frame, - FrameQcResult, FrameRootMotion, Outfit, } from './character' @@ -75,11 +73,3 @@ export type { WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' - -/* Playtest 核验记录 —— 独立于 Character 与 WorkflowRun */ -export type { - PlaytestInspection, - PlaytestInspectionApis, - PlaytestInspectionStatus, - RecordPlaytestInspectionInput, -} from './playtest-inspection' diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts deleted file mode 100644 index e984111..0000000 --- a/frontend/src/entities/playtest-inspection/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** 可持久化的 Playtest 核验结论;没有记录即表示尚未核验。 */ -export type PlaytestInspectionStatus = 'passed' | 'issues_found' - -/** 一次独立核验记录,不属于 Character 或 WorkflowRevision 的内部字段。 */ -export interface PlaytestInspection { - id: string - characterId: string - outfitId: string - /** 仅从 Workflow 进入时保留返回定位;独立入口为 null。 */ - source: { runId: string; revisionId: string } | null - status: PlaytestInspectionStatus - createdAt: string - updatedAt: string -} - -/** 创建或更新指定核验目标结论所需的业务输入。 */ -export interface RecordPlaytestInspectionInput { - characterId: string - outfitId: string - /** 可选的 Workflow 来源,不是播放和核验的前置条件。 */ - source?: { runId: string; revisionId: string } | null - status: PlaytestInspectionStatus -} - -/** PlaytestInspection 对应的一组后端接口。 */ -export interface PlaytestInspectionApis { - getLatest(target: { characterId: string; outfitId: string }): Promise - record(input: RecordPlaytestInspectionInput): Promise -} diff --git a/frontend/src/features/review/index.ts b/frontend/src/features/review/index.ts index 8a897e8..3a48b66 100644 --- a/frontend/src/features/review/index.ts +++ b/frontend/src/features/review/index.ts @@ -1,7 +1,12 @@ -/** 逐帧审核:打回有问题的帧。不设「通过此帧」,默认通过。 */ +/** + * 逐帧查看已生成的动作,供用户在导出前过一遍。 + * + * 只看不改:服务端不返回质检结论,产品上也不设「打回此帧」, + * 所以这里没有任何写操作,帧数据不会因为查看而改变。 + */ export interface ReviewProps { + /** 动作 ID 只在造型内唯一,调用方须自行持有所属造型,不能拿它跨角色定位。 */ actionId: string frameIndex: number onSelectFrame(index: number): void - onRejectFrame(index: number): void } From f9776f9b385d093b2270a086df05b821189c48da Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 17:27:26 +0800 Subject: [PATCH 14/49] docs(entities): mark what the product has not defined yet Three structures carried assumptions a reader could not distinguish from settled decisions. State that multi-direction frame storage is undefined, that the MVP runs a single revision, and that an action id is unique only inside its outfit. Open questions are visible in the contracts instead of being rediscovered later. --- frontend/src/entities/project/index.ts | 6 +++++- frontend/src/entities/workflow-run/index.ts | 8 +++++++- frontend/src/features/generation/index.ts | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 778acf8..4c71164 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -58,7 +58,11 @@ export const CHARACTER_PERSPECTIVE: Record = { isometric: '2.5D', } -/** 移动方向,决定一个动作要生成几套朝向的帧。 */ +/** + * 移动方向,决定一个动作要生成几套朝向的帧。 + * 多朝向在 Action 上如何存放尚未定义,当前 Action.frames 只表达单朝向; + * 选了四向/八向的项目,生成侧还接不上,见 Action.frames 的说明。 + */ export const DIRECTIONAL_MOVEMENT: Record = { single: '单向', 'four-way': '四向', diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index c4a715d..6998d27 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -72,7 +72,13 @@ export interface WorkflowStep { referenceStepIds: string[] } -/** 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 */ +/** + * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 + * + * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 + * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, + * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + */ export interface WorkflowRevision { id: string /** 首次创建的版本没有来源,因此为 null。 */ diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts index 47f5997..2015752 100644 --- a/frontend/src/features/generation/index.ts +++ b/frontend/src/features/generation/index.ts @@ -1,6 +1,10 @@ /** 触发并展示一次生成;不感知后端调用的是哪个模型。 */ export interface GenerationProps { runId: string - /** 生成母版等非动作任务可以省略。 */ + /** + * 目标动作,生成母版等非动作任务可以省略。 + * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, + * 不能脱离 run 单独使用这个字段。 + */ actionId?: string } From a86d23b99b8e6db82db5cd1da7ad673aa92c4f57 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 18:24:18 +0800 Subject: [PATCH 15/49] docs(frontend): remove stale skeleton references The reduced module skeleton no longer includes playtest inspection or shared UI. Remove obsolete API and entity references and align the layout comment with app ownership. Documentation now matches the contracts and directories present in this branch. --- frontend-architecture-v3.md | 3 +-- frontend/API_CONTRACT.md | 1 - frontend/src/app/layout/index.tsx | 5 +---- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 01b8926..1488dfb 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -17,7 +17,6 @@ | `media` | 已上传媒体的不透明引用 | | `task` | 后端异步步骤的状态 | | `workflow-run` | 制作流程的运行记录 | -| `playtest-inspection` | 核验结论,独立于角色与流程 | **模块判据:这个东西能不能被单独取到。** @@ -57,7 +56,7 @@ pages -> features -> entities -> shared ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis -TaskApis WorkflowRunApis PlaytestInspectionApis +TaskApis WorkflowRunApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index f5a3206..0774b9e 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -14,7 +14,6 @@ |---|---| | `WorkflowRunApis`(`get` / `create` / `save`) | 没有 workflow 模块 | | `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| `PlaytestInspectionApis`(`getLatest` / `record`) | 没有对应模块 | | 独立的 `task` 模块 | 后端 Task 不独立,是 `generation` 内的 `GenerationTask` | 前端已按服务端现状去掉 `TaskApis.cancel`——`GenerationService` 没有取消能力,不声明前端用不到的接口。 diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index c69669a..aa21b82 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,10 +1,7 @@ import type { ReactNode } from 'react' import { Link } from 'react-router' -/** - * 模块五:跨页面常驻导航。不属于任何单个页面,故上提到 app 层。 - * 页内头部 PageHeader 在 shared/ui —— pages 不能 import app 层。 - */ +/** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ export interface AppShellProps { /** 渲染在全局导航下方的当前路由页面。 */ From 77fca50ae47a010ffb38e39342f96768ece4964e Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 19:48:32 +0800 Subject: [PATCH 16/49] refactor(character): remove unsupported character name The backend character contract has no standalone name field. Remove name from Character and CreateCharacterInput. The frontend now matches the confirmed character payload boundary. --- frontend/src/entities/character/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index eecd8a1..616b5db 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -111,7 +111,6 @@ export interface Outfit { export interface Character { id: string projectId: string - name: string /** 角色的全部独立造型;MVP 页面至少保留这一层,即使当前只有一个成员。 */ outfits: Outfit[] createdAt: string @@ -121,7 +120,6 @@ export interface Character { /** 创建角色并发起母版生成所需的入参。 */ export interface CreateCharacterInput { projectId: string - name: string /** 交给模型生成母版。 */ description: string referenceImageUrl?: string | null From b03250c23184737cf099d133fe815d7a285897dc Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 19:48:59 +0800 Subject: [PATCH 17/49] refactor(workflow): keep workflow run frontend-owned The fixed workflow is not a backend resource. Remove WorkflowRunApis and update the public and controller contracts. Backend responsibilities now stop at generation and confirmed asset storage. --- frontend/src/entities/index.ts | 3 +-- frontend/src/entities/workflow-run/index.ts | 16 +++++----------- .../src/features/workflow-controller/index.ts | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 6a4ce9c..ac64b4f 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -56,7 +56,7 @@ export type { MediaReference } from './media' /* 后端异步任务 —— 与工作流节点是两回事 */ export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' -/* 工作流 —— 节点由前端推进,运行记录由后端持久化 */ +/* 工作流 —— 节点与运行状态都由前端管理 */ export { WORKFLOW_STEP_ORDER } from './workflow-run' export type { CreateWorkflowRunInput, @@ -69,7 +69,6 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, - WorkflowRunApis, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 6998d27..a2a3aef 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -64,8 +64,9 @@ export interface WorkflowStep { output: unknown /** * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 - * 它随 WorkflowRun 一起持久化,页面重开后据此查回在途任务的状态, - * 因而不会重复发起同一次生成。任务本身不认识步骤,反向关联不存在。 + * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 + * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 + * 任务本身不认识步骤,反向关联不存在。 */ taskId: Task['id'] | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ @@ -98,7 +99,8 @@ export interface WorkflowRevision { /** * 一次由前端推进的页面流程。 - * 步骤怎么走由前端决定;WorkflowRun 本身由后端持久化,前端通过 WorkflowRunApis 读写。 + * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 + * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 */ export interface WorkflowRun { id: string @@ -147,11 +149,3 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & baseFrameUrls: readonly string[] } ) - -/** WorkflowRun 对应的一组后端接口;只负责存取,不负责推进。 */ -export interface WorkflowRunApis { - get(runId: WorkflowRun['id']): Promise - create(input: CreateWorkflowRunInput): Promise - /** 保存已由 WorkflowController 完成合法状态转换的流程快照。 */ - save(run: WorkflowRun): Promise -} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index 3ccff25..f8ce879 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -32,7 +32,7 @@ export interface ApplyServerResultInput { * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 * 步骤数据,不拆成互不共享状态的独立模块。 * - * 步骤怎么走由前端决定,服务端只负责持久化 WorkflowRun 和提供生成能力。 + * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 */ export interface WorkflowController { /** 初始化一条创建角色或增加动作的流程。 */ From 378fee028fbf9f7bdd314120db2a2450bc529feb Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 19:49:21 +0800 Subject: [PATCH 18/49] docs(frontend): record confirmed backend boundaries The API notes still described stale workflow and generation assumptions. Document frontend-owned workflow state, generation mapping, and assigned follow-ups. The architecture now reflects the latest backend reference and team decisions. --- frontend-architecture-v3.md | 8 ++-- frontend/API_CONTRACT.md | 73 ++++++++++++++++++++----------------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 1488dfb..5c06229 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -52,15 +52,17 @@ pages -> features -> entities -> shared ## 3. 接口命名 -每个模块暴露一组后端接口,统一叫 `XxxApis`: +需要访问后端资源的模块暴露一组接口,统一叫 `XxxApis`: ```text ProjectApis CharacterApis ActionTemplateApis GenerationApis -TaskApis WorkflowRunApis +TaskApis ``` **不使用 `Repository` / `Port` / `Adapter` 这些叫法**,也不做接口与实现的分离——实现跟着接口放在同一个模块里。 +`WorkflowRun` 是前端运行态,不声明后端接口。后端不读取、不推进、也不持久化它。 + --- ## 4. 流程推进 @@ -75,7 +77,7 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走由前端决定。** `WorkflowRunApis` 只负责存取运行记录,不负责推进。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 0774b9e..e0cd7d2 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -1,57 +1,63 @@ # 前后端接口对齐清单 -前端各模块的 `XxxApis` 与后端 PR #64(head `10dd958`)逐条比对结果。 +前端各模块的 `XxxApis` 与后端 2026-07-30 接口文档逐条比对结果。 后端现有四个相关模块:`project`、`character`、`generation`、`media`。`asset` 与 `wearable` 已按 07-30 评审要求删除。 --- -## 一、前端预期有、后端目前没有 +## 一、已经确认的边界 -**这几条需要后端明确做或不做;不做的前端删掉。** +- `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 +- `Character` 不使用独立 `name` 字段;前端已删除。 +- 前端保留 `jump` 动作类型,由后端补充对应枚举。 +- 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: -| 前端接口 | 后端情况 | -|---|---| -| `WorkflowRunApis`(`get` / `create` / `save`) | 没有 workflow 模块 | -| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| 独立的 `task` 模块 | 后端 Task 不独立,是 `generation` 内的 `GenerationTask` | +| 前端工作流节点 | 后端接口 | 后端任务类型 | +|---|---|---| +| `character_template` | `POST /generation/image` | `character_image` | +| `first_frame` | `POST /generation/image`,以上一步角色图作为参考图 | `character_image` | +| `complete_animation` | `POST /generation/action`,以已确认动作首帧作为参考图 | `character_action` | -前端已按服务端现状去掉 `TaskApis.cancel`——`GenerationService` 没有取消能力,不声明前端用不到的接口。 +图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 --- -## 二、形状不一致 +## 二、前端预期有、后端目前没有 -### 生成步骤:后端一步,前端两步 +**这些接口仍需要确定由后端提供,还是改为前端本地能力。** -| 后端 `GenerationType` | 前端 `GenerationType` | +| 前端接口 | 后端情况 | |---|---| -| `character_image` | `character_template` | -| `character_action` | `first_frame` → `complete_animation` | +| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | +| `ProjectApis.update` | 没有 `PATCH /projects/{project_id}` | -前端设计是先出动作首帧、用户确认后再生成完整动画。后端 `generate_character_action` 是一次到底。 +前端已按服务端现状去掉 `TaskApis.cancel`——后端没有取消能力,不声明前端用不到的接口。 -**这不是命名差异,是交互差异。需要确认首帧确认这一步保不保。** +--- + +## 三、形状不一致 -### 其他 +这些差异可以在前端接口层转换,不要求领域类型与后端 DTO 使用相同命名。 | 项 | 后端 | 前端 | |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | | 查任务 | `get_task(project_id, task_id)` 需要 `project_id` | `get(taskId)` 只传 taskId | -| 动作类型 | `walk` `idle` `attack` `custom` | 多一个 `jump` | -| 删除项目 | 返回 `bool`(是否找到) | 返回 `void` | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.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`,由前端转换层处理,不需要后端改动。 --- -## 三、后端有、前端没接 +## 四、后端有、前端没接 | 后端 | 说明 | |---|---| -| `project_name_exists(user_id, project_name)` | 建项目时的重名校验,前端未接 | | `delete_character` | 前端 `CharacterApis` 没有删除 | | `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | | `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | @@ -59,7 +65,7 @@ ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不 --- -## 四、审核数据无处保存 +## 五、前端资产字段在后端没有落点 后端 `character_data` 的嵌套结构(见 `character/model.py`): @@ -71,22 +77,18 @@ frames[] → index / image_url / duration_ms 前端以下字段在后端结构里没有落点: -- `Frame.qc`(系统质检结论) -- `Frame.rejected`(人工打回) - `Action.kind`(preset / custom 来源) -- `Action.status`(planned / generating / candidate / confirmed / failed) - `Action.keyFrameIndex` - `Frame.rootMotion` - `Outfit.candidateCharacterTemplates`(母版候选列表) +- `Outfit.characterTemplateUrl`(每套造型的已确认角色图) - `Outfit.baseFrames` -**其中 `qc` 与 `rejected` 是审核台的全部依据。后端不存这两个字段,审核结果就落不了库。** - -`candidateCharacterTemplates` 同理:前端设计是生成多张候选让用户选,后端 `CharacterImageInput.num_images` 默认 1,`character_data` 里也没有候选列表。 +`candidateCharacterTemplates` 属于生成过程数据;若只在当前 WorkflowRun 中使用,可以留在前端。其余字段若要随最终资产恢复,需要后端增加字段,或者前端在 MVP 中删除。 --- -## 五、概念不一致 +## 六、概念不一致 后端 `character/model.py` 字段说明: @@ -103,11 +105,16 @@ frames[] → index / image_url / duration_ms ## 待确认 -- [ ] 第一节四条:后端做还是不做 -- [ ] 动作生成一步还是两步 -- [ ] `Frame.qc` / `Frame.rejected` 落不落库 +- [ ] `ActionTemplateApis` 由后端提供还是前端内置 +- [ ] 查询生成任务统一使用 `taskId`,还是 `projectId + taskId` - [ ] 母版候选几张 - [ ] 参考图与角色图是一个字段还是两个 - [ ] `Character.description` 前端要不要跟着存 -- [ ] `jump` 动作类型后端加不加 +- [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 - [ ] 上传模块何时提交 + +## 已分工 + +- [x] 前端删除 `WorkflowRunApis`,WorkflowRun 全程由前端管理 +- [x] 前端删除 `Character.name` +- [ ] 后端增加 `jump` 动作类型 From 869827b61576837244ad76ac55c688235cfdd7d1 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 21:15:32 +0800 Subject: [PATCH 19/49] fix(tasks): require project context for task lookup The backend scopes generation-task queries by both project and task identifiers. Add projectId to generation reads and task reads and subscriptions. Callers can now map directly to the documented task query endpoint. --- frontend/src/entities/generation/index.ts | 4 ++-- frontend/src/entities/task/index.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 7c932fc..a9d34a1 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -103,6 +103,6 @@ export interface Generation { export interface GenerationApis { /** 创建一次生成任务。 */ create(input: T): Promise> - /** 读取生成任务的最新快照。 */ - get(id: Generation['id']): Promise + /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ + get(projectId: Generation['projectId'], id: Generation['id']): Promise } diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts index bd64883..469d263 100644 --- a/frontend/src/entities/task/index.ts +++ b/frontend/src/entities/task/index.ts @@ -39,8 +39,11 @@ export interface TaskEvent extends Omit + /** + * 按所属项目和后端 Task ID 读取最新快照。 + * projectId 不能从 taskId 推导;后端查询接口要求两者同时传入。 + */ + get(projectId: string, taskId: Task['id']): Promise /** 订阅任务状态变化,返回取消订阅函数。 */ - subscribe(taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void + subscribe(projectId: string, taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void } From 7a6515edc3a1f514f0c999bb9cba1ba88c878078 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 30 Jul 2026 21:15:56 +0800 Subject: [PATCH 20/49] docs(api): record project-scoped task lookup Task lookup ownership is now confirmed against the backend API. Mark projectId plus taskId as resolved and remove the stale mismatch and question. Reviewers can see the current task query boundary without reopening the decision. --- frontend/API_CONTRACT.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index e0cd7d2..45e7189 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -11,6 +11,7 @@ - `WorkflowRun` 是前端固定工作流的运行态。后端不读取、不推进、也不持久化,前端不声明 `WorkflowRunApis`。 - `Character` 不使用独立 `name` 字段;前端已删除。 - 前端保留 `jump` 动作类型,由后端补充对应枚举。 +- 查询生成任务统一携带 `projectId + taskId`。 - 前端工作流节点不与后端 `GenerationType` 一一对应,按下表调用: | 前端工作流节点 | 后端接口 | 后端任务类型 | @@ -44,7 +45,6 @@ |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 查任务 | `get_task(project_id, task_id)` 需要 `project_id` | `get(taskId)` 只传 taskId | | 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`,实现时可封装轮询 | | 图片生成数量 | 入参有 `num_images`,结果只有一个 `image_url` | 角色图候选结果是 `images[]` | | 动作类型 | `walk` `idle` `attack` `custom`;待增加 `jump` | `walk` `idle` `attack` `jump` `custom` | @@ -106,7 +106,6 @@ frames[] → index / image_url / duration_ms ## 待确认 - [ ] `ActionTemplateApis` 由后端提供还是前端内置 -- [ ] 查询生成任务统一使用 `taskId`,还是 `projectId + taskId` - [ ] 母版候选几张 - [ ] 参考图与角色图是一个字段还是两个 - [ ] `Character.description` 前端要不要跟着存 From 43e4809755d924daef7fe90373d9f3025714fd15 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:34:11 +0800 Subject: [PATCH 21/49] feat(generation): define task recovery contract Workflow execution needs a stable boundary between generation records and backend tasks. Parse character-template results and bind generation IDs to task IDs. Require subscriptions to replay a current snapshot before later updates. --- frontend/src/entities/generation/index.ts | 24 +++++++++++++++++++++-- frontend/src/entities/task/index.ts | 5 ++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index a9d34a1..7ca1889 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -1,6 +1,6 @@ import type { ActionType } from '../character' import type { MediaReference } from '../media' -import type { TaskStatus } from '../task' +import type { Task, TaskStatus } from '../task' /** * Generation 是业务数据,不是「调用图片生成能力」。 @@ -60,6 +60,21 @@ export interface CharacterTemplateGenerationResult { images: readonly GeneratedImage[] } +/** Task.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 */ +export function parseCharacterTemplateGenerationResult( + value: unknown, +): CharacterTemplateGenerationResult | null { + if (!isRecord(value) || value.type !== 'character_template' || !Array.isArray(value.images)) { + return null + } + const images = value.images.filter( + (image): image is GeneratedImage => + isRecord(image) && typeof image.url === 'string' && image.url.length > 0, + ) + if (images.length === 0 || images.length !== value.images.length) return null + return { type: 'character_template', images } +} + export interface FirstFrameGenerationResult { type: 'first_frame' image: GeneratedImage @@ -88,7 +103,8 @@ export type GenerationResultFor = * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 */ export interface Generation { - id: string + /** 创建接口返回的后端 Task ID;后续查询与订阅必须把它交给 TaskApis。 */ + id: Task['id'] projectId: string /** 与创建时的输入判别字段保持同一字面量类型。 */ type: TType @@ -106,3 +122,7 @@ export interface GenerationApis { /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ get(projectId: Generation['projectId'], id: Generation['id']): Promise } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts index 469d263..37e0950 100644 --- a/frontend/src/entities/task/index.ts +++ b/frontend/src/entities/task/index.ts @@ -44,6 +44,9 @@ export interface TaskApis { * projectId 不能从 taskId 推导;后端查询接口要求两者同时传入。 */ get(projectId: string, taskId: Task['id']): Promise - /** 订阅任务状态变化,返回取消订阅函数。 */ + /** + * 订阅后必须立即发送一次最新完整快照,随后再发送状态变化;任务已经终止也必须发送。 + * 该语义关闭 get/create 与开始监听之间的终态竞态。 + */ subscribe(projectId: string, taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void } From e8b68ba850839145d339fb68bd7d6c3b454b8319 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:34:36 +0800 Subject: [PATCH 22/49] feat(workflow-run): add local state store Workflow execution needs a frontend-owned snapshot that can survive page refreshes. Add the fixed step model, versioned local storage, and runtime hydration validation. Keep memory authoritative when persistence fails or stored data is invalid. --- .../src/entities/workflow-run/constants.ts | 19 ++ frontend/src/entities/workflow-run/index.ts | 116 +++++--- frontend/src/entities/workflow-run/store.ts | 265 ++++++++++++++++++ 3 files changed, 365 insertions(+), 35 deletions(-) create mode 100644 frontend/src/entities/workflow-run/constants.ts create mode 100644 frontend/src/entities/workflow-run/store.ts diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 0000000..16714ea --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,19 @@ +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-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index a2a3aef..f6bd2a1 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,26 +1,32 @@ +import type { + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, +} from '../generation' +import type { MediaReference } from '../media' import type { Task } from '../task' +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,40 +34,31 @@ 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。 - * 后端 Task 是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + * 后端 Task 是否真正停止是独立问题;当前纵切不提供重启操作。 */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] /** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' +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 的生成任务 ID;没有在途任务时为 null。 * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 @@ -69,16 +66,62 @@ export interface WorkflowStep { * 任务本身不认识步骤,反向关联不存在。 */ taskId: Task['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 +} + +type RemainingWorkflowStepType = Exclude + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选选择及后五步尚未实现,输入输出等对应纵切开始时再收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;候选选择及后五步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | RemainingWorkflowStep + /** * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + * 当前本地存储版本只接受单条执行线:revisions 恒为一个成员, + * basedOnRevisionId 与 restartStepId 恒为 null。「从历史步骤重开并保留旧版本」 + * 尚未进入产品定义;实现时需要同步升级存储版本和迁移规则。 */ export interface WorkflowRevision { id: string @@ -88,8 +131,8 @@ export interface WorkflowRevision { restartStepId: string | null status: WorkflowRevisionStatus /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + * 当前版本固定保存全部八步;数组位置是步骤顺序的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 */ steps: WorkflowStep[] generationStatus: GenerationStatus @@ -149,3 +192,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.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 0000000..1a2f94f --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,265 @@ +import type { WorkflowRun } from './index' +import { parseCharacterTemplateGenerationResult } 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 = 1 + +type WorkflowRunListener = (run: WorkflowRun) => void + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface WorkflowRunStore { + get(runId: WorkflowRun['id']): WorkflowRun | null + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => 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) + ) + } + 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 === 1 && + 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 (currentRevision.basedOnRevisionId !== null || currentRevision.restartStepId !== null) { + 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 + return step.type === 'character-template' && step.status === 'active' + }), + ) +} + +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) || + persisted.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(persisted.runs) + ) { + return [] + } + + return persisted.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } 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>() + + return { + get(runId) { + const run = runs.get(runId) + return run === undefined ? null : 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 { + // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 + } + } + }, + + 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) + } + }, + } +} From 244dd552c02d6ac6ec1ac61fd191443208ef92f8 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:34:59 +0800 Subject: [PATCH 23/49] feat(workflow-controller): run character template generation Quick Start and the editor need one frontend-owned progression boundary. Add character setup updates, generation submission, task recovery, and interruption handling. Advance valid results to candidate selection without exposing unfinished steps. --- frontend/src/entities/index.ts | 10 +- .../workflow-controller/controller.ts | 718 ++++++++++++++++++ .../src/features/workflow-controller/index.ts | 73 +- 3 files changed, 732 insertions(+), 69 deletions(-) create mode 100644 frontend/src/features/workflow-controller/controller.ts diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index ac64b4f..33b1ff8 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,6 +1,6 @@ /** * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * 外部只从这里使用实体契约与已经落地的实体能力。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ @@ -34,6 +34,7 @@ export type { export type { ActionTemplate, ActionTemplateApis } from './action-template' /* 生成 —— 业务数据,不是「调用生成能力」 */ +export { parseCharacterTemplateGenerationResult } from './generation' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -57,8 +58,12 @@ export type { MediaReference } from './media' export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, + CreateWorkflowRunStoreOptions, CreateWorkflowRunInput, ExportStatus, GenerationStatus, @@ -69,6 +74,7 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 0000000..21cd581 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,718 @@ +import { + parseCharacterTemplateGenerationResult, + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CreateWorkflowRunInput, + type GenerationApis, + type Task, + type TaskApis, + type TaskEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +interface ApplyServerResultInput { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +/** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ +export type CreateWorkflowControllerInput = Extract< + CreateWorkflowRunInput, + { purpose: 'create_character' } +> + +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 + + /** + * 推进一个步骤。当前纵切只实现角色资料到角色图生成; + * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + */ + nextStep(runId: WorkflowRun['id']): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore + generationApis: Pick + taskApis: TaskApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +/** + * WorkflowRun 的唯一推进实现。 + * + * Controller 管前端状态和后端任务的关联;Generation 与 Task 只认识自己的 ID, + * 不读取 WorkflowRun、Revision 或 Step。 + * submissions 与 subscriptions 是实例内锁;生产接入必须复用同一个实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + taskApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + 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 getCurrentRevision(run: WorkflowRun) { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision + } + + function getActiveStep(revision: WorkflowRevision) { + return revision.steps.find((step) => step.status === 'active') ?? null + } + + function replaceStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, + ) { + 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 + }), + } + } + + function create(input: CreateWorkflowControllerInput): WorkflowRun { + const prompt = input.prompt?.trim() || null + const runId = createId('run') + const revisionId = createId('revision') + const steps = WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ) + const run: WorkflowRun = { + id: runId, + projectId: input.projectId, + characterId: null, + 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: now(), + }, + ], + prompt, + } + save(run) + return run + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function updateCharacterSetup( + runId: WorkflowRun['id'], + input: CharacterSetupStepInput, + ): WorkflowRun { + const run = requireActiveWorkflow(runId, requireWorkflow) + 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('角色描述不能为空') + + const updated = replaceStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) + return save(updated) + } + + async function nextStep(runId: WorkflowRun['id']): Promise { + const run = requireActiveWorkflow(runId, requireWorkflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + + if (activeStep.type === 'character-template') { + if (activeStep.taskId) { + ensureTaskSubscription(run, revision.id, activeStep.id, activeStep.taskId) + return requireWorkflow(runId) + } + if (!activeStep.input) throw new Error('角色图生成步骤缺少输入快照') + return submitCharacterTemplate(runId, revision.id, activeStep.id) + } + + 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, + } + + const transitioned: WorkflowRun = { + ...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, + } + }), + } + }), + } + save(transitioned) + return submitCharacterTemplate(runId, revision.id, templateStep.id) + } + + function submitCharacterTemplate( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + ) { + const key = submissionKey(runId, revisionId, stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performCharacterTemplateSubmission(runId, revisionId, stepId).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performCharacterTemplateSubmission( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, revisionId, stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createId('submission') + const submitting = replaceStep(before, revisionId, 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 === stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { + throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') + } + + const withTask = replaceStep(latest, revisionId, 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 markGenerationFailed( + runId, + revisionId, + stepId, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + revisionId, + stepId, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, revisionId, stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markGenerationFailed( + runId, + revisionId, + stepId, + 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 = taskApis.subscribe(run.projectId, taskId, (event) => { + handleTaskEvent(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 handleTaskEvent( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + event: TaskEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markGenerationFailed( + runId, + revisionId, + stepId, + taskId, + null, + event.error?.trim() || '角色图生成任务失败', + ) + return + } + if (event.type !== 'character_template') { + markGenerationFailed( + runId, + revisionId, + stepId, + taskId, + null, + '任务结果类型与角色图生成步骤不匹配', + ) + return + } + applyServerResult(runId, { + revisionId, + stepId, + 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 + } + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markGenerationFailed( + run.id, + revision.id, + activeStep.id, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await taskApis.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 markGenerationFailed( + latest.id, + latestRevision.id, + latestStep.id, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleTaskEvent( + latest.id, + latestRevision.id, + 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 markGenerationFailed( + runId, + revision.id, + 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 markGenerationFailed( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === 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 = { + ...replaceStep( + run, + revisionId, + 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, revisionId, stepId, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function interrupt(runId: WorkflowRun['id']): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') return run + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + const latest = requireWorkflow(runId) + if (latest.status !== 'active') return latest + return save({ ...latest, status: 'interrupted' }) + } + + return { + create, + getWorkflow, + subscribe, + updateCharacterSetup, + nextStep, + resume, + interrupt, + } +} + +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 +} + +function requireActiveWorkflow( + runId: WorkflowRun['id'], + getWorkflow: (runId: WorkflowRun['id']) => WorkflowRun, +) { + const run = getWorkflow(runId) + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +function taskEvent(task: Task): TaskEvent { + 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}` +} + +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 f8ce879..fcb6978 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' From 2aef23af8433e4a289184d2cd7f3ae1273c8d29b Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:35:23 +0800 Subject: [PATCH 24/49] test(workflow-run): cover local execution lifecycle Workflow state and async recovery need regression coverage before page integration. Test persistence, submission deduplication, task replay, and interruption races. Verify the first generation slice through the real store and controller. --- .../src/entities/workflow-run/store.test.ts | 238 +++++++ .../workflow-controller/controller.test.ts | 607 ++++++++++++++++++ .../workflow-run.integration.test.ts | 106 +++ 3 files changed, 951 insertions(+) create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-run.integration.test.ts 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 0000000..c165a4e --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,238 @@ +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', + } +} + +describe('createWorkflowRunStore', () => { + 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.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 2, 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' }], + }), + ], + [ + 'multiple revisions in storage version 1', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRun(), + revisions: [ + ...createRun().revisions, + { + ...createRun().revisions[0], + id: 'revision-2', + status: 'abandoned', + }, + ], + }, + ], + }), + ], + [ + 'restart metadata in storage version 1', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRun(), + revisions: [ + { + ...createRun().revisions[0], + restartStepId: 'revision-1:character-setup', + }, + ], + }, + ], + }), + ], + ])('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/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 0000000..b2acd99 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,607 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + WORKFLOW_STEP_ORDER, + type Generation, + type GenerationApis, + type GenerationInput, + type Task, + type TaskApis, + type TaskEvent, + 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 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)) + } + }) + + 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) + } + }) + + return { get, save, subscribe } +} + +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 generationApis: Pick = { + create: vi.fn(createGeneration), + } + + const subscribeTask = vi.fn( + (projectId: string, taskId: string, onEvent: (event: TaskEvent) => void) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }, + ) + + const taskApis: TaskApis = { + get: vi.fn(async () => { + throw new Error('TaskApis.get is not used until a run is resumed') + }), + subscribe: subscribeTask, + } + + const controller = createWorkflowController({ + store, + generationApis, + taskApis, + createId: createIdFactory(), + now: () => NOW, + }) + + return { + controller, + generationApis, + subscribeTask, + store, + taskApis, + getTaskListener(projectId: string, taskId: string) { + return taskListeners.get(`${projectId}:${taskId}`) ?? null + }, + emitTask(projectId: string, taskId: string, event: TaskEvent) { + 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 ', + }) +} + +async function startCharacterTemplate(harness: ReturnType) { + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id) + return run +} + +describe('createWorkflowController', () => { + it('creates one revision with the fixed eight 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), + 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', + '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: [], + }) + 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) + const second = harness.controller.nextStep(run.id) + + 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) + 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) + + 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.taskApis.get).mockResolvedValueOnce({ + id: 'task-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: harness.taskApis.get, + subscribe: resumeSubscribe, + }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(resumed?.id).toBe(run.id) + expect(harness.taskApis.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.taskApis.get).mockResolvedValueOnce({ + id: 'task-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: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: harness.taskApis.get, + 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: Task) => void + const pendingTask = new Promise((resolve) => { + resolveTask = resolve + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + taskApis: { + get: vi.fn(() => pendingTask), + subscribe: resumeSubscribe, + }, + }) + + const resuming = resumedController.resume(run.id) + resumedController.interrupt(run.id) + resolveTask({ + id: 'task-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, + taskApis: harness.taskApis, + }) + + 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) + 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() + }) +}) 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 0000000..ca4cc9a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationInput, + type TaskApis, + type TaskEvent, +} 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: TaskEvent) => 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: Pick = { + create: vi.fn(createGeneration), + } + const taskApis: TaskApis = { + 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, + taskApis, + 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) + + 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' }) + }) +}) From 339edbad18eb12f7e87d0da8a6e9035a293515c3 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 01:35:43 +0800 Subject: [PATCH 25/49] docs(frontend): document workflow runtime boundaries The skeleton documentation no longer reflects the implemented workflow slice. Record controller scope, recovery behavior, task replay, and local persistence limits. Keep editor, Quick Start automation, and later steps explicitly out of scope. --- frontend-architecture-v3.md | 37 +++++++++++++++++++++++++------------ frontend/API_CONTRACT.md | 2 +- frontend/README.md | 6 ++++-- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 5c06229..0c326e6 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 +本文记录当前前端的模块划分、依赖规则和已经落地的首个工作流纵切。 --- @@ -69,7 +69,8 @@ TaskApis `features/workflow-controller` 是快速开始与手动工作流共用的推进边界,不含界面。 -Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 +Controller 围绕同一份 WorkflowRun 提供创建、读取、订阅、当前步骤更新、推进、 +任务恢复、结果写回和中断。这些操作依赖同一份步骤数据,不拆成互不共享状态的独立模块。 步骤顺序固定八步: @@ -77,23 +78,35 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 角色资料 → 角色图 → 候选选择 → 动作资料 → 首帧 → 完整动画 → 审核 → 导出 ``` -**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun,只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。 +**步骤怎么走、运行状态如何保存都由前端决定。** 后端不参与 WorkflowRun, +只接收各节点发起的生成请求,并在最终确认时持久化角色与动作资产。固定八步是当前 +产品流程,不是为了通用编排而写的可配置工作流。 -从历史步骤重开会追加一个新 Revision,旧 Revision 保留为只读历史,不会被改写成失败或完成。 +当前存储版本只支持一个 Revision。从历史步骤重开尚未进入产品定义,Controller +不提前暴露该操作;实现时必须同步升级本地存储版本和迁移规则。 -快速开始与手动模式共用同一份推进逻辑,区别只是前者连续调用、后者一次一步。隐藏步骤不等于跳过步骤——门禁写在流程模型里,不在界面里。 +快速开始与手动模式将共用同一份推进逻辑,但连续自动推进属于 Quick Start 页面接入范围, +当前 Controller 只实现一次推进一个步骤。 + +Controller 的提交锁和任务订阅属于实例状态。页面接入时必须复用同一个 Feature 实例, +不能在组件渲染或路由切换时重复创建。 --- -## 5. 本次不包含 +## 5. 当前实现范围 + +- `WorkflowRun` 的内存状态、版本化 localStorage 镜像和刷新校验 +- `角色资料 → 角色图生成 → 候选选择` 的 Controller 纵切 +- Store、Controller 和纵向流程测试 + +页面、Workflow Editor、Quick Start 自动推进、后五步和真实后端适配器仍未实现。 -- 任何实现代码(真实请求、假数据、组件内部逻辑) -- 测试文件 -- 图片上传模块(体量太小,本次不单独体现) -- 穿戴道具相关(产品侧未设计) -- 第三方登录 +### 恢复边界 -页面当前是占位外壳,只声明路由与模块边界。 +- 已取得 `taskId`:刷新后先查询任务当前状态,未结束才重新订阅。 +- 请求已经发出但尚未取得 `taskId`:后端没有幂等键或按请求标识查询的能力, + 前端将本地 Run 标为失败,不自动重提,避免静默创建重复任务。 +- localStorage 写入失败时当前会话继续使用内存快照;页面提示与重新持久化策略在 UI 接入时补充。 --- diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 45e7189..427b6eb 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -45,7 +45,7 @@ |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`,实现时可封装轮询 | +| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.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` | diff --git a/frontend/README.md b/frontend/README.md index 6e01fa3..a7878ed 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 # 构建 ``` @@ -25,6 +25,8 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 +当前已实现纯前端 `WorkflowRun` 存储,以及 +`角色资料 → 角色图生成 → 候选选择` 的首个 Controller 纵切。页面仍是占位外壳, +后五步和真实 `XxxApis` 实现按模块拆成后续 PR。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 From b72262fc373de9b2a06a436ded2defeb7d002d16 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:03:39 +0800 Subject: [PATCH 26/49] refactor(workflow-controller): separate state and task runtime The workflow controller mixed process coordination, state transitions, and asynchronous task recovery in one file. Extract pure WorkflowRun transitions and the character-template task lifecycle behind the existing controller facade. Keep the public API and runtime behavior unchanged while making later workflow steps easier to add. --- .../character-template-task.ts | 457 +++++++++++++ .../workflow-controller/controller.ts | 644 ++---------------- .../workflow-controller/workflow-state.ts | 217 ++++++ 3 files changed, 721 insertions(+), 597 deletions(-) create mode 100644 frontend/src/features/workflow-controller/character-template-task.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.ts 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 0000000..d0656b7 --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,457 @@ +import { + parseCharacterTemplateGenerationResult, + type GenerationApis, + type Task, + type TaskApis, + type TaskEvent, + 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: Pick + taskApis: TaskApis + createSubmissionId: () => string +} + +/** + * 角色图异步任务的生命周期。 + * + * 它只处理当前角色图步骤与后端 Task 的关联,不决定整个工作流下一步走什么。 + * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 + */ +export function createCharacterTemplateTask({ + store, + generationApis, + taskApis, + 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 + } + if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { + throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') + } + + 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 = taskApis.subscribe(run.projectId, taskId, (event) => { + handleTaskEvent(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 handleTaskEvent( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + taskId: string, + event: TaskEvent, + ) { + 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 taskApis.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 { + handleTaskEvent( + 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: Task): TaskEvent { + 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.ts b/frontend/src/features/workflow-controller/controller.ts index 21cd581..58194dc 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,34 +1,24 @@ -import { - parseCharacterTemplateGenerationResult, - WORKFLOW_STEP_ORDER, - type CharacterSetupStepInput, - type CharacterTemplateGenerationInput, - type CreateWorkflowRunInput, - type GenerationApis, - type Task, - type TaskApis, - type TaskEvent, - type WorkflowRevision, - type WorkflowRun, - type WorkflowRunStore, - type WorkflowStep, - type WorkflowStepStatus, - type WorkflowStepType, +import type { + CharacterSetupStepInput, + GenerationApis, + TaskApis, + WorkflowRun, + WorkflowRunStore, } from '@/entities' - -interface ApplyServerResultInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ - taskId: string - result: unknown -} +import { createCharacterTemplateTask } from './character-template-task' +import { + advanceCharacterSetupState, + createWorkflowRunState, + getActiveStep, + getCurrentRevision, + interruptWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from './workflow-state' /** 首个纵切只开放创建角色;增加动作进入对应步骤实现时再加入 Controller。 */ -export type CreateWorkflowControllerInput = Extract< - CreateWorkflowRunInput, - { purpose: 'create_character' } -> +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput export interface WorkflowController { /** 创建并保存一条纯前端运行记录。 */ @@ -66,17 +56,11 @@ export interface CreateWorkflowControllerOptions { now?: () => string } -interface ActiveSubscription { - runId: WorkflowRun['id'] - stop: () => void -} - /** - * WorkflowRun 的唯一推进实现。 + * Quick Start 与手动工作流共用的流程协调器。 * - * Controller 管前端状态和后端任务的关联;Generation 与 Task 只认识自己的 ID, - * 不读取 WorkflowRun、Revision 或 Step。 - * submissions 与 subscriptions 是实例内锁;生产接入必须复用同一个实例, + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, * 不能在组件渲染期间重复创建。 */ export function createWorkflowController({ @@ -86,8 +70,12 @@ export function createWorkflowController({ createId = createRuntimeId, now = () => new Date().toISOString(), }: CreateWorkflowControllerOptions): WorkflowController { - const submissions = new Map>() - const subscriptions = new Map() + const characterTemplateTask = createCharacterTemplateTask({ + store, + generationApis, + taskApis, + createSubmissionId: () => createId('submission'), + }) function getWorkflow(runId: WorkflowRun['id']) { return store.get(runId) @@ -104,68 +92,14 @@ export function createWorkflowController({ return run } - function getCurrentRevision(run: WorkflowRun) { - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) - return revision - } - - function getActiveStep(revision: WorkflowRevision) { - return revision.steps.find((step) => step.status === 'active') ?? null - } - - function replaceStep( - run: WorkflowRun, - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - update: (step: WorkflowStep) => WorkflowStep, - revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, - ) { - 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 - }), - } - } - function create(input: CreateWorkflowControllerInput): WorkflowRun { - const prompt = input.prompt?.trim() || null - const runId = createId('run') - const revisionId = createId('revision') - const steps = WORKFLOW_STEP_ORDER.map((type, index) => - createInitialStep(type, revisionId, index, prompt), + return save( + createWorkflowRunState(input, { + runId: createId('run'), + revisionId: createId('revision'), + createdAt: now(), + }), ) - const run: WorkflowRun = { - id: runId, - projectId: input.projectId, - characterId: null, - 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: now(), - }, - ], - prompt, - } - save(run) - return run } function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { @@ -176,444 +110,42 @@ export function createWorkflowController({ runId: WorkflowRun['id'], input: CharacterSetupStepInput, ): WorkflowRun { - const run = requireActiveWorkflow(runId, requireWorkflow) - 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('角色描述不能为空') - - const updated = replaceStep(run, revision.id, step.id, (current) => { - if (current.type !== 'character-setup') return current - return { - ...current, - input: { - description, - referenceMedia: [...input.referenceMedia], - }, - } - }) - return save(updated) + return save(updateCharacterSetupState(requireWorkflow(runId), input)) } async function nextStep(runId: WorkflowRun['id']): Promise { - const run = requireActiveWorkflow(runId, requireWorkflow) + 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') { - if (activeStep.taskId) { - ensureTaskSubscription(run, revision.id, activeStep.id, activeStep.taskId) - return requireWorkflow(runId) - } - if (!activeStep.input) throw new Error('角色图生成步骤缺少输入快照') - return submitCharacterTemplate(runId, revision.id, activeStep.id) + return characterTemplateTask.start(runId, { + revisionId: revision.id, + stepId: activeStep.id, + }) } - 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, - } - - const transitioned: WorkflowRun = { - ...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, - } - }), - } - }), - } - save(transitioned) - return submitCharacterTemplate(runId, revision.id, templateStep.id) - } - - function submitCharacterTemplate( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - ) { - const key = submissionKey(runId, revisionId, stepId) - const pending = submissions.get(key) - if (pending) return pending - - const submission = performCharacterTemplateSubmission(runId, revisionId, stepId).finally(() => { - submissions.delete(key) - }) - submissions.set(key, submission) - return submission - } - - async function performCharacterTemplateSubmission( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - ): Promise { - const before = requireWorkflow(runId) - const beforeRevision = getCurrentRevision(before) - const beforeStep = beforeRevision.steps.find((step) => step.id === stepId) - if ( - before.status !== 'active' || - beforeRevision.id !== revisionId || - !beforeStep || - beforeStep.type !== 'character-template' || - beforeStep.status !== 'active' || - !beforeStep.input - ) { - return before - } - if (beforeStep.taskId) { - ensureTaskSubscription(before, revisionId, stepId, beforeStep.taskId) - return before - } - if (beforeStep.submissionId) { - throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') - } - - const submissionId = createId('submission') - const submitting = replaceStep(before, revisionId, 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 === stepId) - if ( - (latest.status !== 'active' && latest.status !== 'interrupted') || - latestRevision.id !== revisionId || - !latestStep || - latestStep.type !== 'character-template' || - latestStep.status !== 'active' || - latestStep.taskId || - latestStep.submissionId !== submissionId - ) { - return latest - } - if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { - throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') - } - - const withTask = replaceStep(latest, revisionId, 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 markGenerationFailed( - runId, - revisionId, - stepId, - generation.id, - null, - generation.error?.trim() || '角色图生成任务失败', - ) - } - if (generation.status === 'completed') { - return applyServerResult(runId, { - revisionId, - stepId, - taskId: generation.id, - result: generation.result, - }) - } - - ensureTaskSubscription(withTask, revisionId, stepId, generation.id) - return requireWorkflow(runId) - } catch (cause) { - markGenerationFailed( - runId, - revisionId, - stepId, - 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 = taskApis.subscribe(run.projectId, taskId, (event) => { - handleTaskEvent(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 handleTaskEvent( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - taskId: string, - event: TaskEvent, - ) { - if (event.taskId !== taskId) return - if (event.status === 'pending' || event.status === 'running') return - if (event.status === 'failed') { - markGenerationFailed( - runId, - revisionId, - stepId, - taskId, - null, - event.error?.trim() || '角色图生成任务失败', - ) - return - } - if (event.type !== 'character_template') { - markGenerationFailed( - runId, - revisionId, - stepId, - taskId, - null, - '任务结果类型与角色图生成步骤不匹配', - ) - return - } - applyServerResult(runId, { - revisionId, - stepId, - 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 - } - if (activeStep.submissionId && !activeStep.taskId) { - if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { - return run - } - return markGenerationFailed( - run.id, - revision.id, - activeStep.id, - null, - activeStep.submissionId, - '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', - ) - } - if (activeStep.taskId) { - const task = await taskApis.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 markGenerationFailed( - latest.id, - latestRevision.id, - latestStep.id, - latestStep.taskId, - null, - '任务查询结果类型与角色图生成步骤不匹配', - ) - } - if (task.status === 'pending' || task.status === 'running') { - ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) - } else { - handleTaskEvent( - latest.id, - latestRevision.id, - latestStep.id, - latestStep.taskId, - taskEvent(task), - ) - } - } - return getWorkflow(runId) + const transitioned = advanceCharacterSetupState(run) + save(transitioned.run) + return characterTemplateTask.start(runId, transitioned.target) } - 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 markGenerationFailed( - runId, - revision.id, - 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 markGenerationFailed( - runId: WorkflowRun['id'], - revisionId: WorkflowRevision['id'], - stepId: WorkflowStep['id'], - expectedTaskId: string | null, - expectedSubmissionId: string | null, - error: string, - ) { - const run = requireWorkflow(runId) - if (run.status !== 'active' || run.currentRevisionId !== revisionId) return run - const revision = getCurrentRevision(run) - const step = revision.steps.find((item) => item.id === 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 = { - ...replaceStep( - run, - revisionId, - 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, revisionId, stepId, step.taskId)) - } - return save(failed) - } - - function stopSubscription(key: string) { - const subscription = subscriptions.get(key) - subscriptions.delete(key) - try { - subscription?.stop() - } catch { - // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 - } + function resume(runId: WorkflowRun['id']) { + return characterTemplateTask.resume(runId) } function interrupt(runId: WorkflowRun['id']): WorkflowRun { const run = requireWorkflow(runId) if (run.status !== 'active') return run - for (const [key, subscription] of subscriptions) { - if (subscription.runId === runId) stopSubscription(key) - } + + characterTemplateTask.stop(runId) const latest = requireWorkflow(runId) if (latest.status !== 'active') return latest - return save({ ...latest, status: 'interrupted' }) + return save(interruptWorkflowRunState(latest)) } return { @@ -627,88 +159,6 @@ export function createWorkflowController({ } } -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 -} - -function requireActiveWorkflow( - runId: WorkflowRun['id'], - getWorkflow: (runId: WorkflowRun['id']) => WorkflowRun, -) { - const run = getWorkflow(runId) - if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) - return run -} - -function taskEvent(task: Task): TaskEvent { - 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}` -} - function createRuntimeId(scope: 'run' | 'revision' | 'submission') { const suffix = typeof globalThis.crypto?.randomUUID === 'function' 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 0000000..47c608a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,217 @@ +import { + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CreateWorkflowRunInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = Extract< + CreateWorkflowRunInput, + { purpose: 'create_character' } +> + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export interface WorkflowStepTarget { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + + return { + id: runId, + projectId: input.projectId, + characterId: null, + outfitId: null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + }, + ], + prompt, + } +} + +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], + }, + } + }) +} + +export function advanceCharacterSetupState(workflow: WorkflowRun): { + 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, + } + + 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, + }, + } +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +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 +} From 6d3f51c4136a88f906d8d0bcc84688e7b678551f Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:03:58 +0800 Subject: [PATCH 27/49] test(workflow-controller): cover extracted state transitions The new pure state boundary needs direct regression coverage alongside the existing controller tests. Cover fixed workflow creation, character setup normalization, and activation of the character-template step. Protect the extracted rules without changing production behavior. --- .../workflow-state.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 frontend/src/features/workflow-controller/workflow-state.test.ts 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 0000000..fc68371 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { + advanceCharacterSetupState, + createWorkflowRunState, + 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 workflow with normalized Quick Start input', () => { + 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-setup', status: 'locked' }, + { type: 'first-frame', status: 'locked' }, + { type: 'complete-animation', status: 'locked' }, + { type: 'review', status: 'locked' }, + { type: 'export', status: 'locked' }, + ]) + expect(run.revisions[0]?.steps[0]?.input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + }) + + 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('activates character-template with its generation input snapshot', () => { + const run = updateCharacterSetupState(createRun(), { + description: 'revised knight', + referenceMedia: [], + }) + + const transitioned = advanceCharacterSetupState(run) + + 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: [], + }, + }, + { type: 'template-candidate', status: 'locked' }, + ]) + }) +}) From 7e4fff2554ff6e9bd6a38b9579eb76d857865f85 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:04:17 +0800 Subject: [PATCH 28/49] fix(character-setup): accept workflow step input The Character Setup feature exposed the asset creation DTO while the workflow controller expects step-local input. Change the submit callback to accept CharacterSetupStepInput. Allow Quick Start and Workflow Editor to connect without translating through an unrelated asset contract. --- frontend/src/features/character-setup/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts index 44c9a7b..13827b1 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 } From f251dddeff4e0efb95cf58e78f43e34460e837fa Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 31 Jul 2026 03:04:42 +0800 Subject: [PATCH 29/49] test(character-setup): lock workflow input contract The Character Setup boundary previously drifted to an unrelated asset DTO. Add a type assertion for the submit callback parameter. Prevent the page-to-workflow contract from regressing. --- frontend/src/features/character-setup/index.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 frontend/src/features/character-setup/index.test.ts 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 0000000..60a05ec --- /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() +}) From ac83b58351ea4406301f39bce37f75cdeb1e6d54 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:23:16 +0800 Subject: [PATCH 30/49] feat(playtest): add isolated preview workbench --- frontend/src/app/app.tsx | 2 + frontend/src/pages/playtest/README.md | 131 ++++++ frontend/src/pages/playtest/demo-page.tsx | 17 + frontend/src/pages/playtest/index.test.tsx | 133 ++++++ frontend/src/pages/playtest/index.tsx | 99 +++- .../playtest/playtest-boundaries.test.ts | 112 +++++ .../playtest/testing/demo-character.test.ts | 42 ++ .../pages/playtest/testing/demo-character.ts | 159 +++++++ .../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 | 52 ++ .../playtest/workbench/action-selector.tsx | 51 ++ .../workbench/analysis/frame-geometry.test.ts | 128 +++++ .../workbench/analysis/frame-geometry.ts | 178 +++++++ .../workbench/analysis/image-geometry.test.ts | 145 ++++++ .../workbench/analysis/image-geometry.ts | 68 +++ .../analysis/sequence-evidence.test.ts | 275 +++++++++++ .../workbench/analysis/sequence-evidence.ts | 444 ++++++++++++++++++ .../use-frame-review-evidence.test.tsx | 203 ++++++++ .../analysis/use-frame-review-evidence.ts | 118 +++++ .../workbench/animation-stage.test.tsx | 169 +++++++ .../playtest/workbench/animation-stage.tsx | 112 +++++ .../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/export/asset-export.test.ts | 177 +++++++ .../playtest/workbench/export/asset-export.ts | 371 +++++++++++++++ .../workbench/export/export-panel.test.tsx | 120 +++++ .../workbench/export/export-panel.tsx | 122 +++++ .../workbench/frame-review-evidence.test.tsx | 212 +++++++++ .../workbench/frame-review-evidence.tsx | 261 ++++++++++ .../playtest/workbench/frame-timeline.tsx | 64 +++ .../pages/playtest/workbench/index.test.tsx | 173 +++++++ .../src/pages/playtest/workbench/index.tsx | 364 ++++++++++++++ .../pages/playtest/workbench/inspector.tsx | 64 +++ .../model/create-preview-model.test.ts | 124 +++++ .../workbench/model/create-preview-model.ts | 94 ++++ .../pages/playtest/workbench/model/types.ts | 45 ++ .../playtest/workbench/playback-controls.tsx | 123 +++++ .../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-keyboard.test.tsx | 265 +++++++++++ .../workbench/use-playtest-keyboard.ts | 91 ++++ 65 files changed, 7062 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/playtest/README.md create mode 100644 frontend/src/pages/playtest/demo-page.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/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/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/export/asset-export.test.ts create mode 100644 frontend/src/pages/playtest/workbench/export/asset-export.ts create mode 100644 frontend/src/pages/playtest/workbench/export/export-panel.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/export/export-panel.tsx 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-keyboard.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index b46ae87..42125fe 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Route, Routes } from 'react-router' import { AssetLibraryPage } from '@/pages/asset-library' import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' +import { PlaytestDemoPage } from '@/pages/playtest/demo-page' import { PlaytestPage } from '@/pages/playtest' import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectsPage } from '@/pages/projects' @@ -27,6 +28,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/playtest/README.md b/frontend/src/pages/playtest/README.md new file mode 100644 index 0000000..2740d1a --- /dev/null +++ b/frontend/src/pages/playtest/README.md @@ -0,0 +1,131 @@ +# Playtest + +Playtest 是角色动画的只读调试和核验界面。它既能直接操控角色验证 idle、walk、jump +之间的响应,也能逐帧查看动作、时长与位移,并在不改变角色素材的前提下记录当前会话的 +整体核验结论。 + +## 入口 + +- **Demo:`/playtest/demo`**。直接使用明确标注的少年开发 fixture,方便独立查看 idle 和 + walk 动作。 +- **正式:`/playtest/:characterId/:outfitId`**。需要由应用提供 `PlaytestPageApis.characters.get`; + 页面只读取指定角色,再从中选择对应 Outfit。 + +正式入口未配置角色接口时,页面会明确显示“Playtest 角色接口尚未配置”。它不会加载或回退到 +Demo 少年数据。 + +## 数据与只读边界 + +Demo 数据来自 `public/playtest-fixtures/boy/` 的已提取少年帧,以及 +`testing/demo-character.ts` 中的开发 fixture。素材来源和用途说明见该目录的 `SOURCE.md`。 + +Playtest 不修改角色、造型、动作、帧、工作流或版本。核验结论和逐帧问题都只保留在当前页面, +不写入本地存储或后端;#70 目前没有独立核验接口,因此此处不猜测持久化合同。 + +#70 的 `Action` 只提供一组 `frames`,Playtest 在页面适配器中把它映射为 `default` 方向, +不会把多方向结构加回 Character 实体。实体的 `rootMotion` 表示相对动作首帧的绝对位置; +播放器使用前会换算为相邻帧增量,避免重复累计。 + +## 统一工作台 + +页面只有一套 `PlaybackController`。动作列表、方向选择、统一舞台、播放控制、时间线, +以及右侧“帧检查 / 问题记录 / 资产导出”三个页签共同读取当前动作、方向和帧。键盘、按钮或 +时间线改变帧后,舞台与右侧工具会同步更新,不存在隐藏的第二套状态。 + +- 左侧动作栏展开宽度为 190px,也可收起为窄条;三栏在宽屏共用相同高度和底边,右侧长内容 + 在栏内滚动,不再向下撑出额外空白。 +- 按住 A/D 时临时播放 `walk` 的逐帧动画,并按各帧 `rootMotion` 移动;松开后,若按键前处于 + 暂停状态则恢复暂停,原本正在播放则继续播放。A 面向左,D 面向右。 +- 没有可播放 `walk` 时,A/D 不改变当前动作、镜像或播放状态。 +- ← / → 才是当前序列的上一帧 / 下一帧控制,长按时可连续切帧。 +- W 选择首个包含有效帧的 jump 动作并从首帧播放。 +- S 选择首个包含有效帧的 crouch 动作并从首帧播放。 +- jump/crouch 缺失时,W/S 不改变当前动作,也不会用其他动作图片冒充。 + +当前 Demo 少年包含 idle 和 walk,不包含 jump 或 crouch,因此界面会明确显示“未提供跳跃 +动作”和“未提供下蹲动作”。以后正式 Character 提供对应动作后,同一套控制器会自动启用 +W/S。 + +## 播放与核验 + +- 初始为暂停;切换动作、方向或手动选择帧时暂停。 +- 每帧的 `durationMs` 优先;未提供有效时长时才按动作 FPS 计算显示与播放间隔。 +- 关闭循环时,播放停留在末帧;开启循环时,末帧会回到首帧。 +- 舞台始终只渲染当前帧角色,不叠加上一帧、下一帧或其他动作的角色虚影。 +- 有横向根位移的角色到达舞台左右边界后会自动掉头并继续播放。边界按舞台和角色的实际显示 + 宽度计算;窗口尺寸改变后会重新钳制。自动掉头不显示文字,也不触发额外播报。 + +## 逐帧自动审核依据 + +逐帧审核会在浏览器中读取每张图片的 Alpha 像素,提供只读的几何检查依据。Alpha 大于 +24 的像素视为候选前景;算法按八邻域过滤小于 `max(4 像素, 最大连通区域的 0.2%)` 的 +孤立噪点,同时始终保留最大主体。 + +右侧分别展示三类位移,不能混为一个数: + +- **画面内额外漂移**:过滤噪点后,相邻帧透明像素质心的变化;自动连续性判断只使用它。 +- **预期根位移增量**:当前帧的 `rootMotion`;它是本次自动播放帧推进应累加的增量,y 正值 + 表示向上。 +- **合成预览位移**:播放器按每次自动播放推进累计根位移后的最终位置;它用于解释播放效果, + 不重复参与自动异常判断。 + +自动审核会输出带问题代码、严重程度和帧位置的结构化结果,覆盖图片不可用、空白主体、画布 +尺寸、边缘裁切、覆盖率、相邻重复帧、位移突变、脚底/高度/面积变化,以及画面位移与根位移 +方向矛盾。重复帧依据主体边界内归一化的 8×8 Alpha/亮度指纹判断,避免小型精灵被整张透明 +画布稀释;位移异常同时使用序列中位数、MAD 和按动作设置的绝对上限,避免整段异常素材用 +自身均值掩盖问题。 + +阈值按动作类别解释:idle 严格检查脚底和高度,walk 允许周期变化,jump 允许离地,crouch +允许主体高度下降,attack/custom 使用更宽的序列离群范围。画布 256×256、主体覆盖率 65%、 +脚底漂移 3 px 和轮廓面积变化 28% 仍作为绝对安全线。 + +这些结果只能发现位置、缩放、背景和轮廓突变,不能判断变脸、人体结构、动作语义或服装 +细节。图片加载失败、完全透明、Canvas 不可用或跨域像素受限时会明确显示“无法计算”,不会 +伪造为 0。检查结果不会修改 Frame、Character 或核验结论;当前 Action 尚无明确循环 +合同,因此不计算循环首尾接缝。 + +## 人工问题记录 + +“问题记录”页签把自动发现和人工标记分开显示。用户可以为当前动作、方向和帧选择问题类型、 +补充说明,并修改或删除人工记录。人工问题只保存在当前 React 会话,刷新后清空,不写入 +localStorage、Character、Frame 或后端。“核验通过 / 发现问题”的总体结论也只保存在当前 +React 会话,且不会被逐帧问题自动改变。 + +## 游戏资产导出 + +“资产导出”页签只导出包含帧的动作序列。单个 ZIP 中只有: + +1. 保留原 MIME/扩展名的逐帧原图; +2. 每个“动作 × 方向”一张 PNG Sprite Sheet; +3. 与每张 Sprite Sheet 对应的 `animation.json`。 + +Sprite Sheet 每行最多 8 帧,不缩放原图;不同尺寸使用序列最大宽高作为单元格并居中。图片 +加载失败时仍生成资产包:对应单元格保持透明,JSON 标记 `available: false`,页面提示导出 +不完整。已有自动或人工问题时,导出页会先提示问题数量但不阻断导出。切换右侧页签不会卸载 +进行中的导出,因此不能通过切换页签并发启动第二份任务。ZIP 不包含 manifest、审核结果、 +核验结论或开发说明。打包采用同一导出文件内的标准无压缩 ZIP 封装,不新增运行时依赖。 + +## 键盘控制 + +| 按键 | 操作 | +| ---------- | ------------------------------------------------ | +| A | 按住时面向左并播放 Walk;松开后恢复原播放状态 | +| D | 按住时面向右并播放 Walk;松开后恢复原播放状态 | +| W | 有 jump 动作时从首帧播放;缺少时不改变当前状态 | +| S | 有 crouch 动作时从首帧播放;缺少时不改变当前状态 | +| Space | 播放或暂停当前动作 | +| ← / → | 当前序列上一帧 / 下一帧,可长按连续切帧 | +| Home / End | 当前序列首帧 / 尾帧 | +| L | 切换循环 | + +焦点位于按钮、输入框、文本区域、选择器或可编辑文本区域时,Playtest 不拦截键盘控制。 +长按 Space、L、W 或 S 不会重复触发;A、D、方向键、Home 和 End 保持可连续触发。 + +## 视觉参考 + +三栏信息布局只参考 `live-demo-ui-components/animation-workbench.tsx` 的视觉层次。统一舞台保留 +旧工程的网格、地面线和透明背景视觉。`rootMotion` 是逐帧位移增量,只在自动播放实际推进 +时累计;它为 `null` 或零时角色原地播放。向左/向右镜像、位移累计、时间线和检查器均由同一 +`PlaybackController` 驱动,不使用前端写死的行走速度、重力或跳跃高度。Playtest 不导入 Cocos +运行时、旧 iframe 或微信小程序适配;当前导出只生成通用逐帧、Sprite Sheet 与 JSON,不生成 +Cocos 或小程序工程。 diff --git a/frontend/src/pages/playtest/demo-page.tsx b/frontend/src/pages/playtest/demo-page.tsx new file mode 100644 index 0000000..ab71b54 --- /dev/null +++ b/frontend/src/pages/playtest/demo-page.tsx @@ -0,0 +1,17 @@ +import { + PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_ACTION_ID, + PLAYTEST_DEMO_OUTFIT_ID, +} from './testing/demo-character' +import { PlaytestWorkbench } from './workbench' + +/** Explicit development fixture entry point. It intentionally bypasses all production APIs. */ +export function PlaytestDemoPage() { + return ( + + ) +} diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx new file mode 100644 index 0000000..81c09c2 --- /dev/null +++ b/frontend/src/pages/playtest/index.test.tsx @@ -0,0 +1,133 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' + +import { PlaytestPage, type PlaytestPageApis } from './index' + +const character: Character = { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: 'Explorer', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/aster.png', + baseFrames: [{ imageUrl: 'https://cdn.example.test/base.png' }], + actions: [ + { + id: 'idle', + outfitId: 'outfit-1', + name: 'Idle', + kind: 'preset', + type: 'idle', + fps: 8, + keyFrameIndex: 0, + frames: [ + { + imageUrl: 'https://cdn.example.test/idle.png', + durationMs: 125, + rootMotion: null, + }, + ], + }, + ], + }, + ], +} + +function renderPage(apis?: PlaytestPageApis, initialEntry = '/playtest/character-1/outfit-1') { + render( + + + } /> + + , + ) +} + +afterEach(() => cleanup()) + +describe('PlaytestPage', () => { + it('shows an explicit unconfigured boundary instead of inventing character data', () => { + renderPage() + + expect(screen.getByText('Playtest 角色接口尚未配置')).toBeTruthy() + }) + + it('loads the requested character through the standard skeleton API only', async () => { + const apis: PlaytestPageApis = { + characters: { get: vi.fn().mockResolvedValue(character) }, + } + + renderPage(apis, '/playtest/character-1/outfit-1?actionId=idle') + + expect(screen.getByText('加载 Playtest 数据中')).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'character-1 · Explorer' })).toBeTruthy() + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith('character-1') + }) + + it.each([{ code: 404 }, { status: 404 }])( + 'maps a missing character response to a stable message', + async (error) => { + renderPage({ characters: { get: vi.fn().mockRejectedValue(error) } }) + + expect(await screen.findByText('角色不存在')).toBeTruthy() + }, + ) + + it('does not mislabel a transport failure as not found', async () => { + renderPage({ + characters: { get: vi.fn().mockRejectedValue(new Error('network unavailable')) }, + }) + + expect(await screen.findByText('角色读取失败')).toBeTruthy() + }) + + it('ignores a stale character response after the route identity changes', async () => { + let resolveFirst: ((value: Character) => void) | undefined + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [{ ...character.outfits[0], characterId: 'character-2' }], + } + const get = vi.fn().mockReturnValueOnce(firstRequest).mockResolvedValueOnce(secondCharacter) + let navigate: NavigateFunction | undefined + + function NavigationProbe() { + navigate = useNavigate() + return null + } + + render( + + + + } + /> + + , + ) + + await act(async () => navigate?.('/playtest/character-2/outfit-1')) + expect(await screen.findByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy() + + await act(async () => resolveFirst?.(character)) + await waitFor(() => + expect(screen.getByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy(), + ) + expect(get).toHaveBeenNthCalledWith(1, 'character-1') + expect(get).toHaveBeenNthCalledWith(2, 'character-2') + }) +}) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 9796e86..99d0a2d 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,9 +1,96 @@ -/** 核验台。 */ -export function PlaytestPage() { +import { useEffect, useState } from 'react' +import { useParams, useSearchParams } from 'react-router' + +import type { Character, CharacterApis } from '@/entities/character' + +import { PlaytestWorkbench } from './workbench' + +export interface PlaytestPageApis { + characters: Pick +} + +export interface PlaytestPageProps { + apis?: PlaytestPageApis +} + +interface PageData { + character: Character | null + error: string | null + loading: boolean +} + +const initialPageData: PageData = { character: null, error: null, loading: false } + +function isNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const identifiable = error as { code?: unknown; status?: unknown } + return ( + identifiable.code === 404 || + identifiable.code === '404' || + identifiable.status === 404 || + identifiable.status === '404' + ) +} + +/** + * 正式 Playtest 页面只读取 #70 已定义的 Character 接口。 + * 核验与自动分析结果均停留在页面会话,不写回资产树。 + */ +export function PlaytestPage({ apis }: PlaytestPageProps) { + const { characterId, outfitId } = useParams() + const [searchParams] = useSearchParams() + const initialActionId = searchParams.get('actionId') + const [data, setData] = useState(initialPageData) + + useEffect(() => { + if (apis === undefined) { + setData({ ...initialPageData, error: 'Playtest 角色接口尚未配置' }) + return + } + if (characterId === undefined || outfitId === undefined) { + setData({ ...initialPageData, error: 'Playtest 路由参数不完整' }) + return + } + + let cancelled = false + setData({ ...initialPageData, loading: true }) + void apis.characters.get(characterId).then( + (character) => { + if (!cancelled) setData({ character, error: null, loading: false }) + }, + (error: unknown) => { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', + }) + } + }, + ) + + return () => { + cancelled = true + } + }, [apis, characterId, outfitId]) + + if (data.error !== null) return {data.error} + if (data.loading || data.character === null) + return 加载 Playtest 数据中 + + return ( + + ) +} + +function PlaytestPageMessage({ children }: { children: string }) { return ( -
-

核验台

-

本次只提交模块划分与接口,页面实现进后续 PR。

-
+
+

{children}

+
) } diff --git a/frontend/src/pages/playtest/playtest-boundaries.test.ts b/frontend/src/pages/playtest/playtest-boundaries.test.ts new file mode 100644 index 0000000..c55992f --- /dev/null +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -0,0 +1,112 @@ +/// + +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const playtestDirectory = fileURLToPath(new URL('.', import.meta.url)) +const entitiesDirectory = fileURLToPath(new URL('../../entities/', import.meta.url)) +const prohibitedImports = [ + 'live-demo-ui-components', + 'entities/workflow-run', + 'entities/generation', +] as const +const allowedEntityImports = ['@/entities/character'] as const + +function sourceFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || entry.name.includes('.test.')) { + return [] + } + return [path] + }) +} + +function allTypeScriptFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return allTypeScriptFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) return [] + return [path] + }) +} + +function moduleSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map( + (match) => match[1] ?? '', + ) +} + +function entityEntry(file: string, dependency: string): string | null { + if (dependency === '@/entities') return '' + if (dependency.startsWith('@/entities/')) return dependency.slice('@/entities/'.length) + if (!dependency.startsWith('.')) return null + + const relativeEntry = relative(entitiesDirectory, resolve(dirname(file), dependency)) + if (relativeEntry.startsWith('..') || isAbsolute(relativeEntry)) return null + + return relativeEntry.replaceAll('\\', '/').replace(/\/index$/, '') +} + +describe('Playtest architecture boundaries', () => { + it('does not import forbidden application or live-demo implementation layers', () => { + // Catches the isolated Playtest slice reaching into unrelated product layers. + for (const file of sourceFiles(playtestDirectory)) { + const source = readFileSync(file, 'utf8') + for (const fragment of prohibitedImports) { + expect(source, `${file} must not contain ${fragment}`).not.toContain(fragment) + } + } + }) + + it('does not import application or capabilities roots or subpaths', () => { + // Catches bypassing the isolated Page through either a barrel or a deep implementation path. + for (const file of sourceFiles(playtestDirectory)) { + const dependencies = moduleSpecifiers(readFileSync(file, 'utf8')) + expect( + dependencies.filter( + (dependency) => + dependency === '@/application' || + dependency.startsWith('@/application/') || + dependency === '@/capabilities' || + dependency.startsWith('@/capabilities/'), + ), + `${file} must not import application or capabilities`, + ).toEqual([]) + } + }) + + it('imports Entity contracts only from the two approved direct entrypoints', () => { + // Catches alias or relative root barrels and deep paths that hide Playtest's dependencies. + for (const file of sourceFiles(playtestDirectory)) { + const entityImports = moduleSpecifiers(readFileSync(file, 'utf8')) + .map((dependency) => ({ dependency, entry: entityEntry(file, dependency) })) + .filter((candidate) => candidate.entry !== null) + + for (const { dependency, entry } of entityImports) { + expect(allowedEntityImports, `${file} imports ${dependency}`).toContain( + `@/entities/${entry}`, + ) + } + } + }) + + it('keeps the demo fixture out of formal Playtest source', () => { + // Catches a formal route silently importing the demo character as an API fallback. + const importers = allTypeScriptFiles(playtestDirectory).filter((file) => + readFileSync(file, 'utf8').includes('testing/demo-character'), + ) + + expect( + importers.every( + (file) => file === join(playtestDirectory, 'demo-page.tsx') || file.includes('.test.'), + ), + ).toBe(true) + expect(readFileSync(join(playtestDirectory, 'index.tsx'), 'utf8')).not.toContain( + 'testing/demo-character', + ) + }) +}) diff --git a/frontend/src/pages/playtest/testing/demo-character.test.ts b/frontend/src/pages/playtest/testing/demo-character.test.ts new file mode 100644 index 0000000..b0ec3ba --- /dev/null +++ b/frontend/src/pages/playtest/testing/demo-character.test.ts @@ -0,0 +1,42 @@ +/// + +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +import { + PLAYTEST_DEMO_ACTION_ID, + PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_OUTFIT_ID, +} from './demo-character' + +describe('PLAYTEST_DEMO_CHARACTER', () => { + it('provides a confirmed boy outfit with complete idle and walk fixtures', () => { + const outfit = PLAYTEST_DEMO_CHARACTER.outfits.find(({ id }) => id === PLAYTEST_DEMO_OUTFIT_ID) + + expect(PLAYTEST_DEMO_CHARACTER.outfits).toHaveLength(1) + expect(PLAYTEST_DEMO_CHARACTER.outfits[0]?.id).toBe(PLAYTEST_DEMO_OUTFIT_ID) + expect(outfit).toBeDefined() + expect(PLAYTEST_DEMO_ACTION_ID).toBe('playtest-demo-boy-idle') + expect(outfit!.actions.map((action) => action.type)).toEqual(['idle', 'walk']) + expect(outfit!.actions.every((action) => action.frames.length === 8)).toBe(true) + + const walkFrames = outfit!.actions.find((action) => action.type === 'walk')!.frames + const walkSteps = walkFrames.flatMap((frame) => + frame.rootMotion === null ? [] : [frame.rootMotion.dx], + ) + expect(walkSteps).toEqual([4, 4, 4, 4, 4, 4]) + expect(walkSteps.reduce((sum, step) => sum + step, 0)).toBe(24) + + const frames = outfit!.actions.flatMap((action) => action.frames) + expect(frames.some((frame) => frame.durationMs !== null)).toBe(true) + expect(frames.some((frame) => frame.rootMotion !== null)).toBe(true) + + const imageUrls = [ + outfit!.characterTemplateUrl, + ...outfit!.candidateCharacterTemplates.map((candidate) => candidate.imageUrl), + ...outfit!.baseFrames.map((frame) => frame.imageUrl), + ...frames.map((frame) => frame.imageUrl), + ] + expect(imageUrls.every((url) => url !== null && existsSync(new URL(url)))).toBe(true) + }) +}) diff --git a/frontend/src/pages/playtest/testing/demo-character.ts b/frontend/src/pages/playtest/testing/demo-character.ts new file mode 100644 index 0000000..f2f0916 --- /dev/null +++ b/frontend/src/pages/playtest/testing/demo-character.ts @@ -0,0 +1,159 @@ +import type { Character } from '../../../entities/character' + +export const PLAYTEST_DEMO_CHARACTER_ID = 'playtest-demo-boy' +export const PLAYTEST_DEMO_OUTFIT_ID = 'playtest-demo-boy-default' +export const PLAYTEST_DEMO_ACTION_ID = 'playtest-demo-boy-idle' + +const fixtureUrls = { + 'base.png': new URL('./fixtures/boy/base.png', import.meta.url).href, + 'idle-01.png': new URL('./fixtures/boy/idle-01.png', import.meta.url).href, + 'idle-02.png': new URL('./fixtures/boy/idle-02.png', import.meta.url).href, + 'idle-03.png': new URL('./fixtures/boy/idle-03.png', import.meta.url).href, + 'idle-04.png': new URL('./fixtures/boy/idle-04.png', import.meta.url).href, + 'idle-05.png': new URL('./fixtures/boy/idle-05.png', import.meta.url).href, + 'idle-06.png': new URL('./fixtures/boy/idle-06.png', import.meta.url).href, + 'idle-07.png': new URL('./fixtures/boy/idle-07.png', import.meta.url).href, + 'idle-08.png': new URL('./fixtures/boy/idle-08.png', import.meta.url).href, + 'walk-01.png': new URL('./fixtures/boy/walk-01.png', import.meta.url).href, + 'walk-02.png': new URL('./fixtures/boy/walk-02.png', import.meta.url).href, + 'walk-03.png': new URL('./fixtures/boy/walk-03.png', import.meta.url).href, + 'walk-04.png': new URL('./fixtures/boy/walk-04.png', import.meta.url).href, + 'walk-05.png': new URL('./fixtures/boy/walk-05.png', import.meta.url).href, + 'walk-06.png': new URL('./fixtures/boy/walk-06.png', import.meta.url).href, + 'walk-07.png': new URL('./fixtures/boy/walk-07.png', import.meta.url).href, + 'walk-08.png': new URL('./fixtures/boy/walk-08.png', import.meta.url).href, +} as const + +const fixtureUrl = (name: keyof typeof fixtureUrls) => fixtureUrls[name] +const demoWalkStep = 4 + +const idleFrames = [ + { + imageUrl: fixtureUrl('idle-01.png'), + durationMs: 160, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-02.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-03.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-04.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-05.png'), + durationMs: 160, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-06.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-07.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('idle-08.png'), + durationMs: null, + rootMotion: null, + }, +] + +const walkFrames = [ + { + imageUrl: fixtureUrl('walk-01.png'), + durationMs: null, + rootMotion: null, + }, + { + imageUrl: fixtureUrl('walk-02.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-03.png'), + durationMs: 120, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-04.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-05.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-06.png'), + durationMs: null, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-07.png'), + durationMs: 120, + rootMotion: { dx: demoWalkStep, dy: 0 }, + }, + { + imageUrl: fixtureUrl('walk-08.png'), + durationMs: null, + rootMotion: null, + }, +] + +export const PLAYTEST_DEMO_CHARACTER: Character = { + id: PLAYTEST_DEMO_CHARACTER_ID, + projectId: 'playtest-demo-project', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: PLAYTEST_DEMO_OUTFIT_ID, + characterId: PLAYTEST_DEMO_CHARACTER_ID, + name: '默认造型', + candidateCharacterTemplates: [ + { + id: 'playtest-demo-boy-template', + imageUrl: fixtureUrl('base.png'), + attemptId: 'playtest-demo-boy-import', + }, + ], + characterTemplateUrl: fixtureUrl('base.png'), + baseFrames: [{ imageUrl: fixtureUrl('base.png') }], + actions: [ + { + id: PLAYTEST_DEMO_ACTION_ID, + outfitId: PLAYTEST_DEMO_OUTFIT_ID, + name: '待机', + kind: 'preset', + type: 'idle', + fps: 8, + keyFrameIndex: 0, + frames: idleFrames, + }, + { + id: 'playtest-demo-boy-walk', + outfitId: PLAYTEST_DEMO_OUTFIT_ID, + name: '行走', + kind: 'preset', + type: 'walk', + fps: 10, + keyFrameIndex: 0, + frames: walkFrames, + }, + ], + }, + ], +} diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md b/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md new file mode 100644 index 0000000..16a42e6 --- /dev/null +++ b/frontend/src/pages/playtest/testing/fixtures/boy/SOURCE.md @@ -0,0 +1,7 @@ +# Playtest fixture source + +- Original project: `E:/Compressed/windup-asset-lab-codex-issue-14-workflow-skeleton_5/windup-asset-lab-codex-issue-14-workflow-skeleton/` +- Original character (`card.json`): `少年 · 默认角色` +- Original `importedFrom`: `default-demo-assets` + +These files are copied only as development test data for the Playtest workbench. diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/base.png b/frontend/src/pages/playtest/testing/fixtures/boy/base.png new file mode 100644 index 0000000000000000000000000000000000000000..7755f3c954aa9518edae0bea484e761ff188c1c5 GIT binary patch literal 12196 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-01.png new file mode 100644 index 0000000000000000000000000000000000000000..7755f3c954aa9518edae0bea484e761ff188c1c5 GIT binary patch literal 12196 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-02.png new file mode 100644 index 0000000000000000000000000000000000000000..4cc3e9b8ce1ae8059a19534c9ac57f3e76556324 GIT binary patch literal 12069 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-05.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd4e01b3f070d7449b706c713d5b074ca69966a GIT binary patch literal 12078 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%0D6kV4NQ1x^#wK|=D?NfGY!9{oL|>=HJ? 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 IhEvS{03tlOrT_o{ literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png b/frontend/src/pages/playtest/testing/fixtures/boy/idle-08.png new file mode 100644 index 0000000000000000000000000000000000000000..67936b2ba569adcd367372eaa599b7afd731e05a GIT binary patch literal 12156 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 O0S`3{)T>nOBL4?kXb&_1 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-01.png new file mode 100644 index 0000000000000000000000000000000000000000..ab3e368e98cea3b11870e615749eda5e11af12e5 GIT binary patch literal 12623 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 wIrX1o`V3nB24dZrLPqib`ceVx^Tjt<8 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-02.png new file mode 100644 index 0000000000000000000000000000000000000000..7e507720759bb2c311088a53b178a1d741aece17 GIT binary patch literal 11763 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¨y=SRj?==YT{GSNklWW~Yz{zfVrj-N2dOt3 zlSrud{mIP=6rxBUEHa(PML=j6m$IUG{=dFdcmL;aJrZ{ z--%`j6u1;KNSOmwOuD!19oGm8RwN*dv3~8s$Wm~1F96{I@er2?P|b-0WaL?Bj6`o= zay`uLLj`QaJqbdEvQ)|cnjxn!hT`CUypd)E*Bz#4#_Vg(Dc^m20Rx<>Pa}|vvN|RM zHKPlZkj4?P;#gzfCDPSxg)q_1B;T_^27rX`|Jpv~#9U_h!g*oALCJ)mz(Ym7^0)c) z-q=7#;M{ z?JmOMi6gqQE9u?prIBA!Kv{+Pa!6u$BjXn;eQsr*3EZe!;1&qBwVRxL8yNKZ;qBdZ z`m@@%5M>Ag(mi{cu(&W_V({rR*~h@}v$c#h31-3Yg~Vs3>GVF=xQ-;i9%8+Gh+eM9 zOr^@Q!%04E9*NYyqw-^#{>fH5gl;eB$ak6DPIjU_z(#F3=PxkCW zL#bw>d)w%oPq#>?;Z_doxz6fH0m7oHN+_mF^xnyRs4l`gTB`M$h%0xeo9iUw7C< z^75QW5ywT9n`9d5(BW05!m=2>OVzJPu28mdX%{&kVC&(Ql#sX@OeB|^4seNmq#b7c z4Bs+NNu%{`L3=UHpO>!WSAQgUJssx8nbJf*8}dGvE~jJ09KJ9W$O=`|A`1zpBLw@n zZ_Ase7{6+%{sUGD{u35_EjD#2>cx|9rI48CpI?HPR8nHBg%$t^AUEN>DYYot@HA58 zGbui`if-QaC_%iZtT^;Z{IKQ+=P`d;!4BFU=xy?nXdP6*?9mQ1a)0P~X$xqZ1y_f) me+nhUSEBfTJ1G3`6|>o={$tBqW5aFc25712sa7c?Q2z%MU*Tl{ literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-03.png new file mode 100644 index 0000000000000000000000000000000000000000..15b97a2731001092fc05117a2a2ec68d6a31a604 GIT binary patch literal 11294 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�P;E7kRr&6lAYr5sRYF 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-04.png new file mode 100644 index 0000000000000000000000000000000000000000..c3ac40e400cdfe8ebeb8b69f7d57817b2d60549c GIT binary patch literal 10956 zcmb_i_dC^Z-2a^89L{j;nZ3smLPqx9Av=3Z%E>P4Sdm>Jlo64cmCQI~?-4RGLWnp< z_IS?s`4gV&xvu-VKA&Ii{T{E^>wUj4ysL>q+&};TfYR1dy9WRu!Y>Gb5feVv-o>r} zAoyBaP1z*y-QR3vAk$>pNwVia64!u1*9(?8ZBHk@CksP9L{JqD_{ih%$MiaH9ygCl z-qf8H7V@15^=<{rhaFw{xb!RiCae&)>JSfER+d{Lbd6QC3(v@3#Jhf)@;$dUYBAb|?n)GHp3kP_S%C@4UxQtX86>O|*^u zR||81WRRYZv^HGNTMJ|zYlcNjses(W<9^i`*nt;v9{>0nM1N_<%W*2dCNrz&-~i!py6=K%cn|tLDV&ld z$s#E@xU=dTJy z+QW(1Iu1nSb`5obOI>ygofvOVP08^X2hR;;_c#pxiK(V-0Tl@j-B8!2>@(+VaIJ zR@RWiMJ@=7rgNj={h`deY@SUklD$KyCQb_{td*rDqcKIGS736`R=_uYt^X!H`v$x7 z%iPrbTSU{Hz(1RIv;J)RzVEX-+Cxw%RMw8FmjQhsBAy?{FZ#fL5uhB}o~d={r{VMb zc~<)OT7LevVXdU2C3|#qw05&d2Qxo0`;Xq_WOa2`t@ts!>dwmpUVhRJKZX{S+TuW#N?HJgN%(c zKuMG^D9l2l*1b>cM|~Jue9p*QsW*SaE)!WO*LkQej1k2z1(m)&?0nWX+ZebQ-FO$K zB$-B!ga`o2de^TWOyup(1E0g@DQD|RhjZlL9$tjzPaE5svx=^+mxTO2^Ls-i%{=P6 z4G{o?*rDuLJbh46dq-D%#!Mwm;AdiK&O#97+K2qWK8!P9MoX;wT-<&+|8#U>LNl&i zMGM5uc=2SNmIn7@ga7+(NdSJ@MSS4L-h+uE<x zj)f7M77T}psac1Y-BT#*<=!{U-^pnupnD_W0p%gvqjuQt@KE@ECavhwvFfwu)WTm+ zMwXT}uOnIP!X}&e7439EgBiDo5QzQdjj?XH`8AI?mZ6d=#Y+6~@GTiH&lv!P*%T8D2x)7%55E;V&0Z@2fh8ntVNoA37z zRCeF)zbj#8FOkB4p2+iKxd$Anq+#j8x#sqMYkYJ4oRdb8{Nj;>tEf@JJarD20g~!S zl@1%7I{c33UkqeC7H2P*5-Ffe3O2Ko;B(Biw!|09g)Qzmu~qj33IV5|7uAU3G-`;v z2cp8r8i6TQ!q~6giqkfLMLDglabvU$^{{SQ?D)MDq+;uvk)BBZ`YNpl-5FyuK`TGN-T+2Ky`u zDYE;JiG z6ZQd=UpaNK?Qa^v(zu-D_@RbZH=sF4`3^ z*E=w;7kdA#;#K(kQPZN|w+>mj2g9wg+;2d3p{I8O*Rul(G#)W4uTYZR#5Xl`A*F4s9H}L8TU!yF{w)NmNt_L?kMaaNUo7Vh*I`)YMQKr(G35K!owZj zY8&UtNgEU>pDu4`LU&+fd(}992lt#i!^|Y#RX&e%`L`_MA4&6ZL}s~lBQf&>rAJ+_ z00Dq{b9Xm7S#byJ_46Op6 zk+eu_4*ZQMizR1X`2m%`3u6eJi6EV+!2yk-KaN`ql+pWwny-Cs5_G)aLvDqgNVs$E zoW9}_t}?8DW)UGsKF^^+12idBJJR* zwfIO?P-tu7s>0*=*6C05}PdYKM%Toy-=Z3+Q)pK zoYdZT4{YJCEi-6Ii|Kb{aiQz*T-~9*OQl0Z@{rEEbS-K#*N;OL!LIG>B5kQ8N_QW9 zz$*RYT+HCuXMdJ2t z(T9E#qK+p)l-2K5*qKCUwKZp?RkwoIZi(L{d1{oraM@3*mL}_M-FR8)_58N(L;FWR zw;LiePi%&>w+My`=SqRvi)V0&JYn4Z97z}1(qi_=J8`Brh^X^U@@v^z@Y$R}wB59@ z4QcJdHOybK^J+NCF9pgj+Hb^C9~d8#Ivp=1?vNgqjBvP`@3ke?cp&@JwO^iMECsCf zu6FJX&WM)=9TEO4alP zWWiqfuPcmncSJ5I!Swh+$kog4r86Q83rmL6EY9mA6*_F6eau_==78FDC-!Q~aMuE< zA!#G^_UNz`A*q9j>R|eUBlj=6vx}rdKig$M9&+dBk-)KH$Nq&JteAMmF1+XJlvefl z*@7ZKb*}k(>J?M~c)jPJjT)&sq|tvfoI6eT&N*pcV}XrwHR!N;`^n$^rL$=FQoAn5 z?fVvWsY*lPPE|hVWPwLgrco;#(r(9UCY(gSaz|QkXLtr$qd^=-HOhpuYC8bH3{@ zGCC)edXw+b*twA8!~hA6@UFyzy4?h%G%-plKZDIG>0+Pw`&vb*PB(4V{O| zS7A{RUd5TE|!TEA81f~%pkks)8e zR30eB+bep1WU}Y3$sZOBv{qIq(L^y9=7#PhHV{1vcAhSU30WLW7Kd=DMh?!i z+lk!}rH9T(QtJGz?on<_w$y-!P&nN900;?^2?|NnbEb@xmJPE4ZdUShqo3EQ13%Zm zE@jk>gaL#?(uK56ZD}ioRZieLf%Nm8W~u%xJnFFB%Bz)iA?D@vq@PRH@!NDeQY+S{ z6s?%cHH^_C6f%kTWD-uzL676~-<|EcZ!h-muj!Sh2}%w>1o_UY{SSnM%ot;YSacBlNN(b6z>Zuc2B z%2rehWC1yAvOZC*bRq9vj+p&?4QwOo=8~M5gshtwSX-X4%GI8KD3z(4s){7p!(T8d z1l^Q7-B4{hULNj?Kc0ev2#eDg>?K`_Vrk_#uirr$tTy`9h2jiYD|#rIoe&J%hpe*9 zPZnm%1&`djy?;kpq=2g^ZO?OH7M+ooM`>Mh>jOR=J-g4AWV3dsbtZ=dhoiApHlts! z-rMs=W=If1jss6#lohC$;qYvKv-&jrd?q5dG~s0F`9onBWt?f*8-qiC_5nyPIo|3{zruU09kW!)8)FSeyY`x)& zHhV)lB??iI$G*x&cja6a5X&TOK@vOXDmj0n}g?XSUjn?Dt-sE5c=S`^~eOoF1rv)uOs*y zm(SFu+a{}=rx=Cp^x%13)DkRVejjw7GL({G<3OdIXEARvn>_`|&c5)8EXJ0LQfR7u zjG>*9QPbfQM2#-gd1Oa%CIlHpdJl-`c(`_X6{@Wd$Ik>+*S6_xcW{FjMe0N*YiIDz8B>Q9$4)MZ9^>5s#t%%{F$a?!pq78G3oMjIWvO)0_KHub@c?&01<<3&9ENr-nM<#vqU)Q<&%4{ zDAU662$!;DeDlj8sV#4t(a~LVQ`r5%tSq>>?R}=4JD$7+>mj3ylkjgzo^dBg0&x&f zo7=n#*!iy8G~x@yMDt;IIf=Ik8ber zNKZ{Ku%#?^J==2V)JgIAam)7G^+4p1Fd*22^bV9S;sP++Q*|L8%iciGvaQ1(|#b~}IZ4W8hmps7Ib z!twEY7BxqXKTgNvo7TIX;P&{G+}vW$QE#rRz!sK2o5wtIsZcG@JWrvhC=d1B+{dJv zhiRvwk0S;if`t7G8ClmBH^~uV^_&TUkWf~0h(|gxhp;o~18&2vWuHnq#9f5+=p-Ma zNcTfwyT&@iL1THXM6D1?ne0+kQ7C`W(p>w7V3JrGB}BQ<{G=aUdb&Zf^>1CL@FXE~ zO48Hj2oV+Cmj!;RPKivA$OQ827I5?07S#yG<>j@Nv3&_R**NBrJ|v+EK5YA) zm53kL6{)6V(+o&z)^hsC3T^#VJEo)T%E*osM3A0eX{2^+uKdNYqlvPfgAdQCn9qpd zdXFAur{=yvvixOPihS1qQA}4!L4(Nzqr8M;zhbn?EjqTcznZ`^I0+$VX)J7Jys+xg zoSCutS{gcq8Za(z20>z_LCOYFNrHNSrl6DtJcHY3kl{i&_O z8TkIOHxjk`a@_|ro5hdH_ohZDpvI1~l{8GIIwB!A={YXhs9frHwlNTX1r$@(Z z@l?Jt)-uHcu)FWk8OHC-o3X!u2!e2cjmI8JNI~rQy|2yo4t>0EEF@O~j&@tsT!3a$ z`(e`><+@Xie03$ zDV~Yo2jdmZ<3Bk&?+_Q}3GivmO-v zAhg)q4fk{L?7Z0NMZ!s;xLammK-5eMm;#Hcn;dtsYa}AhBCDAW9uBJkW3$YFZXlR( zG}pK@ouw^suQW<=Meif|#roKds_8bN4YA3_Tok%J`bttnGYW+@f`Z5)#^F|W zbBs@N_Uon;IREn#;Hi3-;pM3nXMo_1(2cX5Cpaf~|CWFgsRbfo?zQ5%fOWuu-IlG- z6F*Q;rT;dLekxgSQ(jn2wJiRWEzQV%Gbk8k3-LIj!w1Z|ex+{qM@Fpfji ze|x>|s*=bg2-PE3xX2w*dly*?ezNj9hLd40+BhOA!%(0qab8;9;J){e9|m*0d;87t z7v#Ik^**E^$l1?^RRVQqd2nWLxQzuBWZ6%ly}Ms??3?_ahkB;v`x=;m@kZ)%Fqa9H z5QF2ztgupRtFE-Vm;=ks~f&N_|x5r-k>s=>0c9KM&wwEgr#Ac?SnitwEd4xvcuctFt?}CnUJYo&SsvY5{OKx=lEn_>@UaBEtNLw>_5Nz>k z+39?(ppDfT^q*Gy_G&^lYtdZ(d83a!S_w@Ndv0nD{#7DBqcBBgJ=RY0@Hh?A##Rfe z%)a3|jjJP;3cF=NdWf&|$Hfqn9F5`l%dM;Ll`a|^WCGy?dn8&v3o&YMlhvJ9Mq_D5 z`KL}^NA^_PWOzSPDjYqSJ}LIzG2A^lbu$U}XTIbZ8H&KRcd*klogxJz|9*Q(fb9V4 z5k~N%%HmB-lS3RbFVx`cQ5eDj`2p~>_xQwg*F|f2tsx~=g*_Tu+5vrs4A{6;E}g1U z=?LEl2jN~CGt9V6DlDNaVRbVvH|6w*{K;KAuwJ5{G5`)@0<-v?lo>s~|JLjI;d)N; z4>lp_QI9z(H}x}_XTnk5x7jBjK2I8$cxGK~e`{1cxeYY@s^5_ADNR$5lli$r86Fgd zX%gokGa<3RT~JVE$8q6}k@*y8h>-0j2~1GBvfjE7eNP*ay2Kp*I1Hcj$3xI!jGXRb z$(?OMr3ZOXM-;K~CFMYb^#j05>Fr>yU~Ww8kxHY92HkEL9hk#XmyiempydJo7)_VN zG0P;%Wit8;cWd3DJ1GX#kKGjt(T7>LuqnFA#*tl-G*}b}XW`xrkn)4&8)zwbHr_wX zPabjG?Q`3EU$ydF7zoP#O>&&qR8_!~*OYZ@0J1vz-M*c0Lwnn8zgrcID@(Ej6F`C> zgULVq(VSA(X)1$T5K;NStv@rK-yGS?DN@g`SpK%#f{jvW*|}4pRVn!iz8D*S6pmy! z0v0Iv2N&d6@0r}{cgwp7Dj9ZJ8XIz15jn~!etF9Ws}rt;BGA+ny=fEav)@NA?6i13 z=DLsIs(2{2LN!b4%mLpbkwt-Be- z+c_4<)p;I<&ASd2BGj_eE<0+5^g=YG+4Wg^&8Oj*J# z+zLeNS30e_$SS3Gx-7rcnQM!=)!tJ_6ey2N3cGUy1_0)2p0Yn-S>gd6E7 z5#{Wws$-QEXo81`*@-v)jKgTC(g|Wh+O1o3xD^nOxbAJuJHVa7XR zi`mE$%Q6_p{{67wPnSc6Ht9Wa;qw$TJh0;mA&CDxnYwx>i3_Nl8%;gD=3L_k_>Q@g z-VoclDU zEhVg?`?b5Op)I^&?L|*dvg%zmL44wr@)RfYN9YQFTkxKGxda1IM4h-##}f+i?I`BSbAwi~hQA+UodyDTeOIk732gp-L$Fb&f(bxyiW42g7ikm7mbHahKyULHu&8$)jCOVgiGlsk_U7@Y zf{dWkhji8Ci5G9%o|F~geMZ|ft4>JxPKwP=qCb7~OY4Mv@kB7sZR+-7UXJBb5EF*6Z{BTj6$11(V_TZEgLnE1J!QOB zUpBX)>TT*H9_Yu%Dlvbo`dst-co$x&+u`D&B(aX zUwIkOrT9ok9&O)~tv>TV_EhAdEv^B(M=z4Sb zoP-^4I+Xnk_Xc_XDoj$)%;i(`xK_I)D7j%Ugd#$L?Vr!HfNwZC;_K{p`wE8eS4Qlo^+-ZbE0EVmw~odKJNC3okULi%g(Q>rqBQib*K>j; zf8DN!{Azb^18T1S5+djjGt&+KCGb1A0g+^XEP2)uTkQFx3;CGgce)+=l>Vv+Lfvk^ z{J~3?@5h>$eDcQ7VhuN^QcD1gxeE4n8{U(~mA?(eLGBObE*|`m{8D0xT3ig3*v`)rJwUxc z?^miiO)SIhB4gQV07zc-tshF?5~dcKsAHam;Qvdwt~A>zVU@l}LpVMpFgWme=-CMD zRs{e|@nM|Z4R0V4QfTctoIx<9HRkP#++fk0>j!6yV-{DD!Tn7rTqoKLmpV+plnl2PdUH5 zw4hRJ(?)X8D*>*9qEjv6wyA${tCio>Rp1t^FJNKcBi_?l$f5c<X zMHrh%Lu0O5s?2q`8bJeaFq3Tl-HX!z{M7fQBzHz{g=7Fi7g29W5_YAZnl=&U1mD$X_IeDVd@+4obOn^42nT?p#YCBapZKngS^XI(4Pxij@VKk_ zmfP7?I+F=Uin3(XSTFeii#WWB>uCL($bj~WW;{sUY!02-+IqrF$g!t z_JU_hHJq}CUn?m;f71bN&shh!KS>}()u8A})K9Maux+^^wkq^|qy(*8f3PeY{qNs0 z`9et;r(n444F9HYWKUXG4q0DPBO=apac6gvlb&}X-s^BPnabXnC=2)!V0e#<{ zdaj>;f%^GpNV)Sts}$mu^}gzdAJQatoKgovb=gr5CpL25z9HdqyQ^}Xp?f@U5eFJ_ zmc2^K$=@75J%}8H8d4?mV1zQ@?-$?e6H0!!(B|c-t~9htKReX%XI^(z9J1`p;TVuH zwV)o?vp84Sq^yXo6u^^PbUs4>wM@LA3hO%NKgXBa#`r`Sr7O81X8XQ+K-Nd#4oOku z#BI`@c2>L#eU&s;jErF!<4J1!@2>-yda)8O=)V^WfQ8jO3i#Fwe!C71%v~M( z09xhAVa(x!SskA4JFLzlB$o)6r_h=R*Yj{ z18-$(YYL9u$VOzshRaLFriQGtFID)fk&JO5i@}IN(#@KOm67#f|B$`Hm>5l!sN}q7 zUANsO&x5S+B@_{n51N;8uv)ONf8SPrH?&n)YJ*kcP*YM-8C}ecvArWHA=DYrNI_V2 zyyZ$?+Vh&)_kr#(f@b&*5B+g4%D5H+R`6&w}ns5*{yM zEfYXuS6PZ}Dbl848S=w6M$pBkGpas;L}Ky}iOQd3ljZ*&|7%|o#jSYl@IoCpSHV9L zte5s=l2d%H?JSM>!~)x@arx}6eadUo4eJo*xgZ$v_=J5`Jri+rki)l%+?a=L!Azvm zV)hUC1;dcnO&^+Ot?NjZVl4weV9Gq*7EPN6l`K_T;adi%-M{EZ)tA!yR!`;H6x)Q;zAui>QLlNHv`b22D>^J*Ga z=espfo|*eLlQ<@Zi0q~AJ8@>J541s!g238FvV9tRCBy2w{)E9K`?$ksF2}*}3?ahR zQ%($^?RD1F4O;@pgDQA0?h6U#5>Mz9ef#@RB<(Kq0#`Yk(Ap}=Pb`Cg!rD8aasT0H zAbGkT*X9ze{tg#!Qi|e-zV5_{gAXO*1Iw?!NSZDu^WmpgvcRSj`^z(D8Zqv4xHPFS z)}+?PpQ&4YHo4v5K6cg#2-;4SrjOW5SI!gHE?OZI*g->Qi7>G*u`oG zX<#e$&c?5n?ZVBO@H79I z3X8ILyG_?Vl6rf;*VMwlPBv7`>njHY5j75aI>~qF41|=1<27d;LQDlNMqVVdc!!Ysxe01^K8K@AJdlK@4Bn{;<3fd)I#b_!Rcys_h=!9#_^c zfAw)$o=93+8ODT3&>wt(!J0i7rL4E&Xu>B9Q&thj}f|tLv&xn&v-f zK9!e7e_pU}3!5K#S@}m*MV$ho{C{eolEG_md+dX?8L6dx!YfrkTm7!uCl&jM{{et& BImG|~ literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png b/frontend/src/pages/playtest/testing/fixtures/boy/walk-05.png new file mode 100644 index 0000000000000000000000000000000000000000..12970d8ac8403033d3daf51de2f56d3ef552b204 GIT binary patch literal 11112 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 dtnVQxs+skue?A`(0x^~ZHS^^^k=VN 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/playtest/workbench/acceptance.tsx b/frontend/src/pages/playtest/workbench/acceptance.tsx new file mode 100644 index 0000000..f94546e --- /dev/null +++ b/frontend/src/pages/playtest/workbench/acceptance.tsx @@ -0,0 +1,52 @@ +import { StatusPanel } from './status-panel' + +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +export interface AcceptanceProps { + inspectionStatus: PlaytestInspectionStatus | null + onRecordStatus(status: PlaytestInspectionStatus): void +} + +function statusText(status: PlaytestInspectionStatus | null): string { + if (status === 'passed') return '通过' + if (status === 'issues_found') return '发现问题' + return '尚未核验' +} + +/** 本次浏览会话的临时核验结论,不写回 Character 或任何后端记录。 */ +export function Acceptance({ inspectionStatus, onRecordStatus }: AcceptanceProps) { + return ( +
    +
    +

    ACCEPTANCE

    +

    本次核验

    +
    + +

    {statusText(inspectionStatus)}

    +

    仅保存在当前页面,不写入后端

    +
    +
    + + +
    +
    + ) +} diff --git a/frontend/src/pages/playtest/workbench/action-selector.tsx b/frontend/src/pages/playtest/workbench/action-selector.tsx new file mode 100644 index 0000000..92edb4b --- /dev/null +++ b/frontend/src/pages/playtest/workbench/action-selector.tsx @@ -0,0 +1,51 @@ +import type { PreviewAction } from './model/types' + +export interface ActionSelectorProps { + actions: readonly PreviewAction[] + selectedActionId: string | null + onSelectAction(actionId: string): void +} + +function frameCount(action: PreviewAction): number { + return action.sequences.reduce((total, sequence) => total + sequence.frames.length, 0) +} + +export function ActionSelector({ actions, selectedActionId, onSelectAction }: ActionSelectorProps) { + return ( + + ) +} diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts new file mode 100644 index 0000000..a1d7089 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { measureFrameGeometry, type FramePixelData } from './frame-geometry' + +function createPixels( + width: number, + height: number, + visible: readonly { x: number; y: number; alpha: number }[], +): FramePixelData { + const data = new Uint8ClampedArray(width * height * 4) + + for (const pixel of visible) data[(pixel.y * width + pixel.x) * 4 + 3] = pixel.alpha + + return { data, width, height } +} + +describe('measureFrameGeometry', () => { + it('treats only alpha values greater than 24 as visible', () => { + // Catches the review algorithm including matte noise at the old Alpha cutoff. + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 24 }]))).toBeNull() + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 25 }]))).toMatchObject({ + opaquePixels: 1, + coverageRatio: 1, + }) + }) + + it('measures bounds, centroid, foot line, height, area and coverage from visible pixels', () => { + // Catches off-by-one bounds or a centroid derived from the box instead of real visible pixels. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 1, y: 1, alpha: 255 }, + { x: 2, y: 1, alpha: 255 }, + { x: 1, y: 2, alpha: 255 }, + { x: 2, y: 2, alpha: 255 }, + ]), + ) + + expect(geometry).toEqual({ + width: 4, + height: 4, + bounds: { left: 1, top: 1, right: 2, bottom: 2, width: 2, height: 2 }, + centroid: { x: 1.5, y: 1.5 }, + footY: 2, + subjectHeight: 2, + opaquePixels: 4, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + }) + expect(geometry?.fingerprint).toHaveLength(64) + }) + + it('produces different compact fingerprints for different silhouettes with equal bounds', () => { + const leftTop = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 2, y: 0, alpha: 255 }, + { x: 3, y: 0, alpha: 255 }, + { x: 3, y: 1, alpha: 255 }, + { x: 3, y: 2, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + const leftBottom = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 0, y: 2, alpha: 255 }, + { x: 0, y: 3, alpha: 255 }, + { x: 1, y: 3, alpha: 255 }, + { x: 2, y: 3, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + + expect(leftTop?.bounds).toEqual(leftBottom?.bounds) + expect(leftTop?.fingerprint).not.toEqual(leftBottom?.fingerprint) + }) + + it('keeps small dark silhouettes distinguishable instead of diluting them across the canvas', () => { + const topLeft: Array<{ x: number; y: number; alpha: number }> = [] + const bottomRight: Array<{ x: number; y: number; alpha: number }> = [] + for (let y = 96; y < 160; y += 1) { + for (let x = 96; x < 160; x += 1) { + if (y < 128 || x < 112) topLeft.push({ x, y, alpha: 255 }) + if (y >= 128 || x >= 144) bottomRight.push({ x, y, alpha: 255 }) + } + } + const first = measureFrameGeometry(createPixels(256, 256, topLeft)) + const second = measureFrameGeometry(createPixels(256, 256, bottomRight)) + const distance = + first?.fingerprint?.reduce( + (total, value, index) => total + Math.abs(value - (second?.fingerprint?.[index] ?? value)), + 0, + ) ?? 0 + + expect(first?.bounds).toEqual(second?.bounds) + expect(distance / 64).toBeGreaterThan(0.02) + }) + + it('rejects an RGBA buffer whose dimensions do not match its length', () => { + // Catches silent geometry corruption when Canvas data and dimensions diverge. + expect(() => + measureFrameGeometry({ data: new Uint8ClampedArray(4), width: 2, height: 2 }), + ).toThrowError('RGBA 像素长度与画布尺寸不一致') + }) + + it('ignores a tiny isolated Alpha component outside the visible subject', () => { + // Catches one stray generated pixel moving the measured foot line and centroid. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 1, y: 1, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + + expect(geometry).toMatchObject({ + bounds: { left: 0, top: 0, right: 1, bottom: 1, width: 2, height: 2 }, + centroid: { x: 0.5, y: 0.5 }, + footY: 1, + opaquePixels: 4, + coverageRatio: 0.25, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts new file mode 100644 index 0000000..2f5914e --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts @@ -0,0 +1,178 @@ +export const ALPHA_THRESHOLD = 24 +const MIN_COMPONENT_PIXELS = 4 +const RELATIVE_COMPONENT_RATIO = 0.002 + +export interface FramePixelData { + data: Uint8ClampedArray + width: number + height: number +} + +export interface FrameGeometry { + width: number + height: number + bounds: { + left: number + top: number + right: number + bottom: number + width: number + height: number + } + centroid: { x: number; y: number } + footY: number + subjectHeight: number + opaquePixels: number + coverageRatio: number + /** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */ + fingerprint?: readonly number[] +} + +function createFingerprint( + data: Uint8ClampedArray, + width: number, + subjectPixels: readonly number[], + bounds: { left: number; top: number; width: number; height: number }, +): readonly number[] { + const sums = new Float64Array(64) + const cellPixels = new Uint32Array(64) + + for (let y = 0; y < bounds.height; y += 1) { + for (let x = 0; x < bounds.width; x += 1) { + const cellX = Math.min(7, Math.floor((x * 8) / bounds.width)) + const cellY = Math.min(7, Math.floor((y * 8) / bounds.height)) + cellPixels[cellY * 8 + cellX] += 1 + } + } + + for (const index of subjectPixels) { + const x = index % width + const y = Math.floor(index / width) + const offset = index * 4 + const red = data[offset] ?? 0 + const green = data[offset + 1] ?? 0 + const blue = data[offset + 2] ?? 0 + const alpha = (data[offset + 3] ?? 0) / 255 + const luminance = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255 + const cellX = Math.min(7, Math.floor(((x - bounds.left) * 8) / bounds.width)) + const cellY = Math.min(7, Math.floor(((y - bounds.top) * 8) / bounds.height)) + sums[cellY * 8 + cellX] += alpha * (0.25 + luminance * 0.75) + } + + return Array.from(sums, (sum, index) => { + const count = cellPixels[index] ?? 0 + return count === 0 ? 0 : Number((sum / count).toFixed(4)) + }) +} + +function visibleComponents(data: Uint8ClampedArray, width: number, height: number): number[][] { + const visible = new Uint8Array(width * height) + const visited = new Uint8Array(width * height) + const components: number[][] = [] + + for (let index = 0; index < visible.length; index += 1) { + const alpha = data[index * 4 + 3] + if (alpha !== undefined && alpha > ALPHA_THRESHOLD) visible[index] = 1 + } + + for (let start = 0; start < visible.length; start += 1) { + if (visible[start] === 0 || visited[start] === 1) continue + + const component: number[] = [] + const queue = [start] + visited[start] = 1 + + for (let head = 0; head < queue.length; head += 1) { + const index = queue[head] + if (index === undefined) continue + component.push(index) + + const x = index % width + const y = Math.floor(index / width) + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) continue + const nextX = x + offsetX + const nextY = y + offsetY + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) continue + + const next = nextY * width + nextX + if (visible[next] === 0 || visited[next] === 1) continue + visited[next] = 1 + queue.push(next) + } + } + } + + components.push(component) + } + + return components +} + +export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | null { + const { data, width, height } = pixels + + if (data.length !== width * height * 4) { + throw new RangeError('RGBA 像素长度与画布尺寸不一致') + } + + const components = visibleComponents(data, width, height) + if (components.length === 0) return null + + const largestSize = Math.max(...components.map((component) => component.length)) + const minimumSize = Math.min( + largestSize, + Math.max(MIN_COMPONENT_PIXELS, Math.ceil(largestSize * RELATIVE_COMPONENT_RATIO)), + ) + const subjectPixels = components.flatMap((component) => + component.length === largestSize || component.length >= minimumSize ? component : [], + ) + + let left = width + let top = height + let right = -1 + let bottom = -1 + let opaquePixels = 0 + let sumX = 0 + let sumY = 0 + + for (const index of subjectPixels) { + const x = index % width + const y = Math.floor(index / width) + left = Math.min(left, x) + top = Math.min(top, y) + right = Math.max(right, x) + bottom = Math.max(bottom, y) + opaquePixels += 1 + sumX += x + sumY += y + } + + const subjectWidth = right - left + 1 + const subjectHeight = bottom - top + 1 + + return { + width, + height, + bounds: { + left, + top, + right, + bottom, + width: subjectWidth, + height: subjectHeight, + }, + centroid: { x: sumX / opaquePixels, y: sumY / opaquePixels }, + footY: bottom, + subjectHeight, + opaquePixels, + coverageRatio: opaquePixels / (width * height), + fingerprint: createFingerprint(data, width, subjectPixels, { + left, + top, + width: subjectWidth, + height: subjectHeight, + }), + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts new file mode 100644 index 0000000..507435a --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { readImageGeometry } from './image-geometry' + +type ImageBehavior = 'load' | 'error' | 'pending' + +let imageBehavior: ImageBehavior +let crossOriginAtSourceAssignment: string | null +let lastAssignedSource: string +let canvasContext: Pick | null + +class FakeImage { + crossOrigin: string | null = null + naturalWidth = 2 + naturalHeight = 2 + onerror: OnErrorEventHandler | null = null + onload: ((this: GlobalEventHandlers, event: Event) => unknown) | null = null + private source = '' + + get src(): string { + return this.source + } + + set src(value: string) { + this.source = value + crossOriginAtSourceAssignment = this.crossOrigin + lastAssignedSource = value + if (value === '' || imageBehavior === 'pending') return + + queueMicrotask(() => { + if (imageBehavior === 'load') this.onload?.call(this as never, new Event('load')) + else this.onerror?.call(this as never, 'error', '', 0, 0, new Error('load failed')) + }) + } +} + +function pixelsWithAlpha(alpha: number): ImageData { + const data = new Uint8ClampedArray(2 * 2 * 4) + data[3] = alpha + return { data, width: 2, height: 2, colorSpace: 'srgb' } as ImageData +} + +beforeEach(() => { + imageBehavior = 'load' + crossOriginAtSourceAssignment = null + lastAssignedSource = '' + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(255)), + } + vi.stubGlobal('Image', FakeImage) + vi.spyOn(document, 'createElement').mockImplementation(((tagName: string) => { + if (tagName !== 'canvas') + return document.createElementNS('http://www.w3.org/1999/xhtml', tagName) + return { + width: 0, + height: 0, + getContext: () => canvasContext, + } as unknown as HTMLCanvasElement + }) as typeof document.createElement) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('readImageGeometry', () => { + it('requests anonymous image access before reading real Canvas pixels', async () => { + // Catches crossOrigin being assigned after src or a placeholder geometry replacing actual pixels. + const result = await readImageGeometry('https://cdn.example.test/frame.png') + + expect(crossOriginAtSourceAssignment).toBe('anonymous') + expect(result).toEqual({ + status: 'ready', + geometry: { + width: 2, + height: 2, + bounds: { left: 0, top: 0, right: 0, bottom: 0, width: 1, height: 1 }, + centroid: { x: 0, y: 0 }, + footY: 0, + subjectHeight: 1, + opaquePixels: 1, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + }, + }) + }) + + it('reports asset, Canvas and transparent-frame failures instead of zero evidence', async () => { + // Catches unavailable evidence being silently presented as a successful zero measurement. + imageBehavior = 'error' + await expect(readImageGeometry('/missing.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片加载失败', + }) + + imageBehavior = 'load' + canvasContext = null + await expect(readImageGeometry('/no-canvas.png')).resolves.toEqual({ + status: 'unavailable', + reason: '浏览器无法读取图片像素', + }) + + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(24)), + } + await expect(readImageGeometry('/transparent.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片没有可见主体', + }) + }) + + it('reports a tainted Canvas as a cross-origin pixel failure', async () => { + // Catches signed remote images being mislabelled as transparent or valid when CORS blocks inspection. + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => { + throw new DOMException('tainted', 'SecurityError') + }), + } + + await expect(readImageGeometry('https://remote.example.test/frame.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片跨域,无法计算像素', + }) + }) + + it('cancels a pending image read through AbortSignal', async () => { + // Catches a stale sequence load surviving a direction switch and updating the new review. + imageBehavior = 'pending' + const controller = new AbortController() + const result = readImageGeometry('/slow.png', controller.signal) + + controller.abort() + + await expect(result).resolves.toEqual({ + status: 'unavailable', + reason: '分析已取消', + }) + expect(lastAssignedSource).toBe('') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts new file mode 100644 index 0000000..bb8e7b1 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts @@ -0,0 +1,68 @@ +import { measureFrameGeometry } from './frame-geometry' +import type { FrameGeometryResult } from './sequence-evidence' + +function unavailable(reason: string): FrameGeometryResult { + return { status: 'unavailable', reason } +} + +function pixelReadFailure(error: unknown): FrameGeometryResult { + if (error instanceof DOMException && error.name === 'SecurityError') { + return unavailable('图片跨域,无法计算像素') + } + + return unavailable('浏览器无法读取图片像素') +} + +export function readImageGeometry( + imageUrl: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { + const image = new Image() + let settled = false + + const finish = (result: FrameGeometryResult) => { + if (settled) return + settled = true + image.onload = null + image.onerror = null + signal?.removeEventListener('abort', abort) + resolve(result) + } + const abort = () => { + image.src = '' + finish(unavailable('分析已取消')) + } + + image.crossOrigin = 'anonymous' + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext('2d', { willReadFrequently: true }) + + if (context === null) { + finish(unavailable('浏览器无法读取图片像素')) + return + } + + try { + context.drawImage(image, 0, 0) + const imageData = context.getImageData(0, 0, canvas.width, canvas.height) + const geometry = measureFrameGeometry(imageData) + finish(geometry === null ? unavailable('图片没有可见主体') : { status: 'ready', geometry }) + } catch (error) { + finish(pixelReadFailure(error)) + } + } + image.onerror = () => finish(unavailable('图片加载失败')) + + if (signal?.aborted) { + abort() + return + } + + signal?.addEventListener('abort', abort, { once: true }) + image.src = imageUrl + }) +} diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts new file mode 100644 index 0000000..5c875a9 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' + +import type { FrameGeometry } from './frame-geometry' +import { buildSequenceEvidence, type FrameEvidenceInput } from './sequence-evidence' + +function geometry( + overrides: Partial< + Pick< + FrameGeometry, + 'width' | 'height' | 'footY' | 'subjectHeight' | 'opaquePixels' | 'coverageRatio' + > + > & { x?: number; y?: number; fingerprint?: readonly number[]; cropped?: boolean } = {}, +): FrameGeometry { + const subjectHeight = overrides.subjectHeight ?? 20 + + return { + width: overrides.width ?? 256, + height: overrides.height ?? 256, + bounds: { + left: overrides.cropped ? 0 : 100, + top: overrides.cropped ? 0 : 100, + right: overrides.cropped ? 9 : 109, + bottom: overrides.cropped ? subjectHeight - 1 : 100 + subjectHeight - 1, + width: 10, + height: subjectHeight, + }, + centroid: { x: overrides.x ?? 0, y: overrides.y ?? 0 }, + footY: overrides.footY ?? 100, + subjectHeight, + opaquePixels: overrides.opaquePixels ?? 100, + coverageRatio: overrides.coverageRatio ?? 0.25, + fingerprint: overrides.fingerprint, + } +} + +function ready( + value: FrameGeometry, + rootMotion: FrameEvidenceInput['rootMotion'] = null, +): FrameEvidenceInput { + return { geometry: { status: 'ready', geometry: value }, rootMotion } +} + +describe('buildSequenceEvidence', () => { + it('returns structured findings for incomplete, cropped and duplicate frames', () => { + const fingerprint = Array.from({ length: 64 }, (_, index) => index / 64) + const evidence = buildSequenceEvidence( + [ + ready(geometry({ cropped: true, fingerprint })), + ready(geometry({ fingerprint })), + { geometry: { status: 'unavailable', reason: '图片没有可见主体' }, rootMotion: null }, + ], + 'walk', + ) + + expect(evidence.complete).toBe(false) + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['subject_cropped', 'duplicate_frame', 'blank_subject']), + ) + expect(evidence.findings.find((finding) => finding.code === 'duplicate_frame')).toMatchObject({ + frameIndex: 1, + severity: 'warning', + }) + }) + + it('uses action-aware findings for foot and height changes', () => { + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 30 })), + ready(geometry({ footY: 112, subjectHeight: 15 })), + ] + + expect(buildSequenceEvidence(frames, 'idle').findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['foot_drift', 'height_drift']), + ) + expect( + buildSequenceEvidence(frames, 'jump').findings.map((finding) => finding.code), + ).not.toContain('foot_drift') + expect( + buildSequenceEvidence(frames, 'crouch').findings.map((finding) => finding.code), + ).not.toContain('height_drift') + }) + + it('flags motion outliers and root-motion direction contradictions', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 }), { dx: 1, dy: 0 }), + ready(geometry({ x: 2 }), { dx: 1, dy: 0 }), + ready(geometry({ x: -18 }), { dx: 5, dy: 0 }), + ], + 'walk', + ) + + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['motion_spike', 'root_motion_mismatch']), + ) + }) + + it('calculates a selected frame delta and the hand-derived sequence baseline', () => { + // Catches image-derived offsets being calculated from bounds or against the first frame instead of the previous frame. + const evidence = buildSequenceEvidence( + [ready(geometry()), ready(geometry({ x: 3, y: 4, opaquePixels: 80 }))], + 'walk', + ) + + expect(evidence.frames[0]?.previousDelta).toBeNull() + expect(evidence.frames[1]?.previousDelta).toEqual({ + dx: 3, + dy: 4, + distance: 5, + areaDeltaPercent: 20, + }) + expect(evidence.summary).toMatchObject({ + medianStep: 5, + maxStep: 5, + movementThreshold: 15, + maxAreaDeltaPercent: 20, + movementState: 'normal', + areaState: 'normal', + }) + }) + + it('does not compare across an unreadable middle frame', () => { + // Catches filtered valid frames becoming false neighbours and producing a misleading offset. + const evidence = buildSequenceEvidence( + [ + ready(geometry()), + { geometry: { status: 'unavailable', reason: '图片加载失败' }, rootMotion: null }, + ready(geometry({ x: 20, y: 20 })), + ], + 'walk', + ) + + expect(evidence.complete).toBe(false) + expect(evidence.unavailableFrameCount).toBe(1) + expect(evidence.frames.map((frame) => frame.previousDelta)).toEqual([null, null, null]) + expect(evidence.summary.medianStep).toBeNull() + expect(evidence.summary.movementState).toBe('not_applicable') + }) + + it('marks excessive coverage, foot drift and height drift with action-aware states', () => { + // Catches jump lift being rejected as foot drift or foreground/background problems being hidden. + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 20, coverageRatio: 0.66 })), + ready(geometry({ footY: 104, subjectHeight: 28 })), + ] + + const walk = buildSequenceEvidence(frames, 'walk') + const jump = buildSequenceEvidence(frames, 'jump') + + expect(walk.frames[0]?.coverageState).toBe('anomaly') + expect(walk.summary).toMatchObject({ + footDrift: 4, + heightDrift: 8, + footState: 'anomaly', + heightThreshold: 12, + heightState: 'normal', + }) + expect(jump.summary.footState).toBe('attention') + }) + + it('flags a single movement spike against the sequence median', () => { + // Catches a sudden position jump being normalized away by the animation's ordinary movement. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 })), + ready(geometry({ x: 2 })), + ready(geometry({ x: 22 })), + ], + 'walk', + ) + + expect(evidence.summary).toMatchObject({ + medianStep: 1, + maxStep: 20, + movementThreshold: 6, + movementState: 'anomaly', + }) + expect(evidence.frames.map((frame) => frame.movementState)).toEqual([ + 'not_applicable', + 'normal', + 'normal', + 'anomaly', + ]) + }) + + it('keeps an action-aware absolute movement ceiling when every step is large', () => { + // Catches an entire drifting sequence normalizing its own 100px jumps through the median. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0 })), ready(geometry({ x: 100 })), ready(geometry({ x: 200 }))], + 'walk', + ) + + expect(evidence.summary.movementThreshold).toBeLessThan(100) + expect(evidence.summary.movementState).toBe('anomaly') + expect(evidence.findings.map((finding) => finding.code)).toContain('motion_spike') + }) + + it('marks jump and crouch height variation as action-allowed attention', () => { + const frames = [ready(geometry({ subjectHeight: 20 })), ready(geometry({ subjectHeight: 60 }))] + + for (const actionType of ['jump', 'crouch'] as const) { + const evidence = buildSequenceEvidence(frames, actionType) + expect(evidence.summary.heightThreshold).toBeNull() + expect(evidence.summary.heightState).toBe('attention') + expect(evidence.findings.map((finding) => finding.code)).not.toContain('height_drift') + } + }) + + it('flags adjacent outline area changes over 28 percent', () => { + // Catches a character silhouette abruptly shrinking without a visible review warning. + const evidence = buildSequenceEvidence( + [ready(geometry({ opaquePixels: 100 })), ready(geometry({ opaquePixels: 70 }))], + 'attack', + ) + + expect(evidence.summary).toMatchObject({ + maxAreaDeltaPercent: 30, + areaState: 'anomaly', + }) + expect(evidence.frames[1]?.areaState).toBe('anomaly') + }) + + it('marks adjacency-only checks not applicable for one frame', () => { + // Catches the first frame displaying fabricated zero deltas as a successful comparison. + const evidence = buildSequenceEvidence([ready(geometry())], 'idle') + + expect(evidence.frames[0]).toMatchObject({ + previousDelta: null, + movementState: 'not_applicable', + areaState: 'not_applicable', + }) + expect(evidence.summary).toMatchObject({ + footDrift: null, + heightDrift: null, + medianStep: null, + movementThreshold: null, + movementState: 'not_applicable', + areaState: 'not_applicable', + }) + }) + + it('combines measured image drift with adjacent root-motion increments using an upward y axis', () => { + // Catches root motion being subtracted twice or image-space positive-down y being added as positive-up. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0, y: 10 }), null), ready(geometry({ x: 3, y: 14 }), { dx: 10, dy: 6 })], + 'walk', + ) + + expect(evidence.frames[0]).toMatchObject({ + expectedRootDelta: null, + composedPreviewDelta: null, + }) + expect(evidence.frames[1]?.expectedRootDelta).toMatchObject({ dx: 10, dy: 6 }) + expect(evidence.frames[1]?.expectedRootDelta?.distance).toBeCloseTo(Math.sqrt(136)) + expect(evidence.frames[1]?.composedPreviewDelta).toMatchObject({ dx: 13, dy: 2 }) + expect(evidence.frames[1]?.composedPreviewDelta?.distance).toBeCloseTo(Math.sqrt(173)) + expect(evidence.frames[1]?.movementState).toBe('normal') + }) + + it('treats each frame root motion as an increment instead of subtracting the previous frame', () => { + // Catches repeated per-frame dx values collapsing to zero and leaving a walking sprite in place. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0, y: 10 }), null), + ready(geometry({ x: 1, y: 11 }), { dx: 2, dy: 1 }), + ready(geometry({ x: 3, y: 12 }), { dx: 3, dy: 2 }), + ], + 'walk', + ) + + expect(evidence.frames[2]?.expectedRootDelta).toMatchObject({ dx: 3, dy: 2 }) + expect(evidence.frames[2]?.composedPreviewDelta).toMatchObject({ dx: 5, dy: 1 }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts new file mode 100644 index 0000000..a2faad2 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts @@ -0,0 +1,444 @@ +import type { Frame } from '@/entities/character' + +import type { PlaytestActionType } from '../model/types' +import type { FrameGeometry } from './frame-geometry' + +const EXPECTED_CANVAS_SIZE = 256 +const MAX_COVERAGE_RATIO = 0.65 +const MAX_FOOT_DRIFT = 3 +const MAX_HEIGHT_DRIFT = 7 +const MAX_AREA_DELTA_PERCENT = 28 +const MIN_COVERAGE_RATIO = 0.005 +const DUPLICATE_DISTANCE = 0.02 + +export type EvidenceState = 'normal' | 'attention' | 'anomaly' | 'not_applicable' + +export type QualityFindingCode = + | 'image_unavailable' + | 'blank_subject' + | 'canvas_size_mismatch' + | 'subject_cropped' + | 'coverage_too_low' + | 'coverage_too_high' + | 'duplicate_frame' + | 'motion_spike' + | 'foot_drift' + | 'height_drift' + | 'area_spike' + | 'root_motion_mismatch' + +export interface QualityFinding { + code: QualityFindingCode + severity: 'warning' | 'error' + frameIndex: number | null + message: string + metrics: Readonly> +} + +export type FrameGeometryResult = + | { status: 'ready'; geometry: FrameGeometry } + | { status: 'unavailable'; reason: string } + +export interface FrameEvidenceInput { + geometry: FrameGeometryResult + rootMotion: Frame['rootMotion'] +} + +export interface AdjacentFrameDelta { + dx: number + dy: number + distance: number + areaDeltaPercent: number +} + +export interface MotionVector { + dx: number + dy: number + distance: number +} + +export interface FrameReviewEvidence { + geometry: FrameGeometry | null + unavailableReason: string | null + previousDelta: AdjacentFrameDelta | null + expectedRootDelta: MotionVector | null + composedPreviewDelta: MotionVector | null + coverageState: EvidenceState + movementState: EvidenceState + areaState: EvidenceState +} + +export interface SequenceReviewEvidence { + complete: boolean + unavailableFrameCount: number + frames: readonly FrameReviewEvidence[] + findings: readonly QualityFinding[] + summary: { + footDrift: number | null + heightDrift: number | null + medianStep: number | null + maxStep: number | null + movementThreshold: number | null + heightThreshold: number | null + maxAreaDeltaPercent: number | null + canvasState: EvidenceState + footState: EvidenceState + heightState: EvidenceState + movementState: EvidenceState + areaState: EvidenceState + } +} + +function medianAbsoluteDeviation(values: readonly number[], center: number | null): number | null { + if (center === null || values.length === 0) return null + return median(values.map((value) => Math.abs(value - center))) +} + +function fingerprintDistance( + left: readonly number[] | undefined, + right: readonly number[] | undefined, +): number | null { + if ( + left === undefined || + right === undefined || + left.length === 0 || + left.length !== right.length + ) + return null + + const total = left.reduce( + (sum, value, index) => sum + Math.abs(value - (right[index] ?? value)), + 0, + ) + return total / left.length +} + +function isCropped(geometry: FrameGeometry): boolean { + return ( + geometry.bounds.left <= 1 || + geometry.bounds.top <= 1 || + geometry.bounds.right >= geometry.width - 2 || + geometry.bounds.bottom >= geometry.height - 2 + ) +} + +function heightFindingThreshold(actionType: PlaytestActionType): number | null { + if (actionType === 'jump' || actionType === 'crouch') return null + if (actionType === 'idle') return 7 + if (actionType === 'walk') return 12 + return 20 +} + +function absoluteMovementThreshold(actionType: PlaytestActionType): number { + if (actionType === 'idle') return 6 + if (actionType === 'walk') return 24 + if (actionType === 'crouch') return 16 + if (actionType === 'jump') return 64 + return 48 +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null + + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + const upper = sorted[middle] + if (upper === undefined) return null + + if (sorted.length % 2 === 1) return upper + + const lower = sorted[middle - 1] + return lower === undefined ? upper : (lower + upper) / 2 +} + +function spread(values: readonly number[]): number | null { + if (values.length < 2) return null + return Math.max(...values) - Math.min(...values) +} + +function adjacentDelta(previous: FrameGeometry, current: FrameGeometry): AdjacentFrameDelta { + const dx = current.centroid.x - previous.centroid.x + const dy = current.centroid.y - previous.centroid.y + + return { + dx, + dy, + distance: Math.hypot(dx, dy), + areaDeltaPercent: + (Math.abs(current.opaquePixels - previous.opaquePixels) / + Math.max(current.opaquePixels, previous.opaquePixels)) * + 100, + } +} + +function motionVector(dx: number, dy: number): MotionVector { + return { dx, dy, distance: Math.hypot(dx, dy) } +} + +function rootMotion(frame: FrameEvidenceInput): { dx: number; dy: number } { + return frame.rootMotion ?? { dx: 0, dy: 0 } +} + +export function buildSequenceEvidence( + inputs: readonly FrameEvidenceInput[], + actionType: PlaytestActionType, +): SequenceReviewEvidence { + const results = inputs.map((input) => input.geometry) + const readyGeometries = results.flatMap((result) => + result.status === 'ready' ? [result.geometry] : [], + ) + const deltas = results.map((result, index): AdjacentFrameDelta | null => { + const previous = results[index - 1] + if (index === 0 || previous?.status !== 'ready' || result.status !== 'ready') return null + return adjacentDelta(previous.geometry, result.geometry) + }) + const rootDeltas = inputs.map((input, index): MotionVector | null => { + if (index === 0) return null + + const increment = rootMotion(input) + return motionVector(increment.dx, increment.dy) + }) + const availableDeltas = deltas.flatMap((delta) => (delta === null ? [] : [delta])) + const steps = availableDeltas.map((delta) => delta.distance) + const areaDeltas = availableDeltas.map((delta) => delta.areaDeltaPercent) + const medianStep = median(steps) + const movementMad = medianAbsoluteDeviation(steps, medianStep) + const relativeMovementThreshold = + medianStep === null + ? null + : Math.max(medianStep * 2.6 + 2, medianStep + (movementMad ?? 0) * 3 + 2, 6) + const movementThreshold = + relativeMovementThreshold === null + ? null + : Math.min(relativeMovementThreshold, absoluteMovementThreshold(actionType)) + const maxStep = steps.length === 0 ? null : Math.max(...steps) + const maxAreaDeltaPercent = areaDeltas.length === 0 ? null : Math.max(...areaDeltas) + const footDrift = spread(readyGeometries.map((geometry) => geometry.footY)) + const heightDrift = spread(readyGeometries.map((geometry) => geometry.subjectHeight)) + const unavailableFrameCount = results.length - readyGeometries.length + const findings: QualityFinding[] = [] + const heightThreshold = heightFindingThreshold(actionType) + + const frames = results.map((result, index): FrameReviewEvidence => { + const expectedRootDelta = rootDeltas[index] ?? null + if (result.status === 'unavailable') { + return { + geometry: null, + unavailableReason: result.reason, + previousDelta: null, + expectedRootDelta, + composedPreviewDelta: null, + coverageState: 'not_applicable', + movementState: 'not_applicable', + areaState: 'not_applicable', + } + } + + const delta = deltas[index] ?? null + const composedPreviewDelta = + delta === null || expectedRootDelta === null + ? null + : motionVector(delta.dx + expectedRootDelta.dx, expectedRootDelta.dy - delta.dy) + return { + geometry: result.geometry, + unavailableReason: null, + previousDelta: delta, + expectedRootDelta, + composedPreviewDelta, + coverageState: result.geometry.coverageRatio > MAX_COVERAGE_RATIO ? 'anomaly' : 'normal', + movementState: + delta === null || movementThreshold === null + ? 'not_applicable' + : delta.distance > movementThreshold + ? 'anomaly' + : 'normal', + areaState: + delta === null + ? 'not_applicable' + : delta.areaDeltaPercent > MAX_AREA_DELTA_PERCENT + ? 'anomaly' + : 'normal', + } + }) + + results.forEach((result, index) => { + if (result.status === 'unavailable') { + const blank = result.reason.includes('没有可见主体') + findings.push({ + code: blank ? 'blank_subject' : 'image_unavailable', + severity: 'error', + frameIndex: index, + message: blank ? '当前帧没有可见主体' : result.reason, + metrics: {}, + }) + return + } + + const { geometry } = result + if (geometry.width !== EXPECTED_CANVAS_SIZE || geometry.height !== EXPECTED_CANVAS_SIZE) { + findings.push({ + code: 'canvas_size_mismatch', + severity: 'error', + frameIndex: index, + message: '画布尺寸与预期规格不一致', + metrics: { width: geometry.width, height: geometry.height }, + }) + } + if (isCropped(geometry)) { + findings.push({ + code: 'subject_cropped', + severity: 'error', + frameIndex: index, + message: '主体接触画布边缘,可能发生裁切', + metrics: { + left: geometry.bounds.left, + top: geometry.bounds.top, + right: geometry.bounds.right, + bottom: geometry.bounds.bottom, + }, + }) + } + if (geometry.coverageRatio < MIN_COVERAGE_RATIO) { + findings.push({ + code: 'coverage_too_low', + severity: 'warning', + frameIndex: index, + message: '主体在画布中的占比过小', + metrics: { coverageRatio: geometry.coverageRatio }, + }) + } else if (geometry.coverageRatio > MAX_COVERAGE_RATIO) { + findings.push({ + code: 'coverage_too_high', + severity: 'error', + frameIndex: index, + message: '主体在画布中的占比过大', + metrics: { coverageRatio: geometry.coverageRatio }, + }) + } + + const previous = results[index - 1] + if (previous?.status !== 'ready') return + const duplicateDistance = fingerprintDistance( + previous.geometry.fingerprint, + geometry.fingerprint, + ) + if (duplicateDistance !== null && duplicateDistance <= DUPLICATE_DISTANCE) { + findings.push({ + code: 'duplicate_frame', + severity: 'warning', + frameIndex: index, + message: '当前帧与上一帧高度相似', + metrics: { distance: duplicateDistance }, + }) + } + + const delta = deltas[index] + if (delta !== null && movementThreshold !== null && delta.distance > movementThreshold) { + findings.push({ + code: 'motion_spike', + severity: 'error', + frameIndex: index, + message: '相邻帧出现异常位移突变', + metrics: { distance: delta.distance, threshold: movementThreshold }, + }) + } + if (delta !== null && delta.areaDeltaPercent > MAX_AREA_DELTA_PERCENT) { + findings.push({ + code: 'area_spike', + severity: 'warning', + frameIndex: index, + message: '相邻帧主体轮廓面积变化过大', + metrics: { percent: delta.areaDeltaPercent }, + }) + } + + const expected = rootDeltas[index] + if (delta !== null && expected !== null && expected.distance >= 2 && delta.distance >= 2) { + const dot = delta.dx * expected.dx + -delta.dy * expected.dy + if (dot < 0) { + findings.push({ + code: 'root_motion_mismatch', + severity: 'warning', + frameIndex: index, + message: '画面内位移方向与预期根位移矛盾', + metrics: { dotProduct: dot }, + }) + } + } + }) + + if (footDrift !== null && footDrift > MAX_FOOT_DRIFT && actionType !== 'jump') { + findings.push({ + code: 'foot_drift', + severity: 'error', + frameIndex: null, + message: '序列脚底线漂移超过动作允许范围', + metrics: { drift: footDrift, threshold: MAX_FOOT_DRIFT }, + }) + } + if (heightDrift !== null && heightThreshold !== null && heightDrift > heightThreshold) { + findings.push({ + code: 'height_drift', + severity: 'warning', + frameIndex: null, + message: '序列主体高度变化超过动作允许范围', + metrics: { drift: heightDrift, threshold: heightThreshold }, + }) + } + + return { + complete: results.length > 0 && unavailableFrameCount === 0, + unavailableFrameCount, + frames, + findings, + summary: { + footDrift, + heightDrift, + medianStep, + maxStep, + movementThreshold, + heightThreshold, + maxAreaDeltaPercent, + canvasState: + readyGeometries.length === 0 + ? 'not_applicable' + : readyGeometries.every( + (geometry) => + geometry.width === EXPECTED_CANVAS_SIZE && + geometry.height === EXPECTED_CANVAS_SIZE, + ) + ? 'normal' + : 'anomaly', + footState: + footDrift === null + ? 'not_applicable' + : footDrift <= MAX_FOOT_DRIFT + ? 'normal' + : actionType === 'jump' + ? 'attention' + : 'anomaly', + heightState: + heightDrift === null + ? 'not_applicable' + : heightThreshold === null + ? heightDrift > MAX_HEIGHT_DRIFT + ? 'attention' + : 'normal' + : heightDrift > heightThreshold + ? 'anomaly' + : 'normal', + movementState: + maxStep === null || movementThreshold === null + ? 'not_applicable' + : maxStep > movementThreshold + ? 'anomaly' + : 'normal', + areaState: + maxAreaDeltaPercent === null + ? 'not_applicable' + : maxAreaDeltaPercent > MAX_AREA_DELTA_PERCENT + ? 'anomaly' + : 'normal', + }, + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx new file mode 100644 index 0000000..c259088 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx @@ -0,0 +1,203 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewSequence } from '../model/types' +import type { FrameGeometry } from './frame-geometry' +import type { FrameGeometryResult } from './sequence-evidence' +import { useFrameReviewEvidence, type ImageGeometryReader } from './use-frame-review-evidence' + +function sequence(...imageUrls: string[]): PreviewSequence { + return { + direction: 'south', + frames: imageUrls.map((imageUrl) => ({ + imageUrl, + durationMs: 100, + rootMotion: null, + keyFrame: false, + })), + } +} + +function geometry(x: number, y = 10): FrameGeometryResult { + const value: FrameGeometry = { + width: 256, + height: 256, + bounds: { left: x, top: 0, right: x + 9, bottom: 19, width: 10, height: 20 }, + centroid: { x, y }, + footY: 19, + subjectHeight: 20, + opaquePixels: 100, + coverageRatio: 100 / (256 * 256), + } + return { status: 'ready', geometry: value } +} + +function deferred(): { + promise: Promise + resolve(value: T): void +} { + let resolvePromise: ((value: T) => void) | null = null + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + return { + promise, + resolve(value) { + if (resolvePromise === null) throw new Error('deferred promise is not initialized') + resolvePromise(value) + }, + } +} + +afterEach(cleanup) + +describe('useFrameReviewEvidence', () => { + it('exposes loading before producing real sequence evidence', async () => { + // Catches the Inspector flashing fabricated zeros before image analysis completes. + const pending = deferred() + const reader: ImageGeometryReader = () => pending.promise + const { result } = renderHook(() => + useFrameReviewEvidence(sequence('/frame-1.png'), 'walk', reader), + ) + + expect(result.current).toEqual({ status: 'loading', evidence: null }) + + await act(async () => pending.resolve(geometry(4))) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(4) + }) + + it('ignores a previous sequence result that resolves after a direction switch', async () => { + // Catches a slow old direction replacing the evidence for the currently selected direction. + const south = deferred() + const north = deferred() + const reader: ImageGeometryReader = (imageUrl) => + imageUrl.includes('south') ? south.promise : north.promise + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: sequence('/south.png') } }, + ) + + rerender({ current: sequence('/north.png') }) + await act(async () => north.resolve(geometry(20))) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20)) + + await act(async () => south.resolve(geometry(2))) + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20) + }) + + it('passes an abort signal to image reads and aborts stale sequence work', () => { + const pending = deferred() + const reader = vi.fn(() => pending.promise) + const { rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: sequence('/south.png') } }, + ) + + const firstSignal = reader.mock.calls[0]?.[1] + expect(firstSignal).toBeInstanceOf(AbortSignal) + expect(firstSignal?.aborted).toBe(false) + + rerender({ current: sequence('/north.png') }) + + expect(firstSignal?.aborted).toBe(true) + expect(reader.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal) + }) + + it('reuses settled image results when frames are selected or revisited', async () => { + // Catches frame navigation repeatedly decoding every image in the same review session. + const reader = vi.fn(async (imageUrl) => + geometry(imageUrl.includes('one') ? 1 : 2), + ) + const first = sequence('/one.png') + const second = sequence('/two.png') + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: first } }, + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + rerender({ current: first }) + expect(reader).toHaveBeenCalledTimes(1) + + rerender({ current: second }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) + rerender({ current: first }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(1)) + expect(reader).toHaveBeenCalledTimes(2) + }) + + it('retries an unavailable image when its sequence is revisited', async () => { + // A transient image failure must not become a permanent session-level cache entry. + let failedOnce = false + const reader = vi.fn(async (imageUrl) => { + if (imageUrl.includes('retry') && !failedOnce) { + failedOnce = true + return { status: 'unavailable', reason: 'temporary failure' } + } + return geometry(imageUrl.includes('retry') ? 9 : 2) + }) + const retrySequence = sequence('/retry.png') + const otherSequence = sequence('/other.png') + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: retrySequence } }, + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.complete).toBe(false) + + rerender({ current: otherSequence }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) + rerender({ current: retrySequence }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(9)) + + expect(reader).toHaveBeenCalledTimes(3) + }) + + it('stays idle without a review sequence and does not read an image', () => { + // Catches direct-control mode starting hidden Canvas work. + const reader = vi.fn() + const { result } = renderHook(() => useFrameReviewEvidence(null, null, reader)) + + expect(result.current).toEqual({ status: 'idle', evidence: null }) + expect(reader).not.toHaveBeenCalled() + }) + + it('does not update React state after the consumer unmounts', async () => { + // Catches an image completion writing into a removed Playtest workbench. + const pending = deferred() + const reader: ImageGeometryReader = () => pending.promise + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const mounted = renderHook(() => useFrameReviewEvidence(sequence('/slow.png'), 'walk', reader)) + + mounted.unmount() + await act(async () => pending.resolve(geometry(1))) + + expect(consoleError).not.toHaveBeenCalled() + }) + + it('keeps each preview frame root motion beside its measured geometry', async () => { + // Catches asynchronous image results losing their matching motion contract before aggregation. + const base = sequence('/first.png', '/second.png') + const motionSequence: PreviewSequence = { + ...base, + frames: [ + { ...base.frames[0]!, rootMotion: null }, + { ...base.frames[1]!, rootMotion: { dx: 10, dy: 6 } }, + ], + } + const reader: ImageGeometryReader = async (imageUrl) => + imageUrl.includes('first') ? geometry(0, 10) : geometry(3, 14) + const { result } = renderHook(() => useFrameReviewEvidence(motionSequence, 'walk', reader)) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.frames[1]).toMatchObject({ + expectedRootDelta: { dx: 10, dy: 6 }, + composedPreviewDelta: { dx: 13, dy: 2 }, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts new file mode 100644 index 0000000..ad70061 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react' + +import type { PlaytestActionType, PreviewFrame, PreviewSequence } from '../model/types' +import { readImageGeometry } from './image-geometry' +import { + buildSequenceEvidence, + type FrameGeometryResult, + type SequenceReviewEvidence, +} from './sequence-evidence' + +export type FrameReviewEvidenceState = + | { status: 'idle'; evidence: null } + | { status: 'loading'; evidence: null } + | { status: 'ready'; evidence: SequenceReviewEvidence } + +export type ImageGeometryReader = ( + imageUrl: string, + signal?: AbortSignal, +) => Promise + +interface CachedReads { + reader: ImageGeometryReader + entries: Map +} + +interface ResolvedState { + key: string | null + value: FrameReviewEvidenceState +} + +const IDLE_STATE: FrameReviewEvidenceState = { status: 'idle', evidence: null } +const LOADING_STATE: FrameReviewEvidenceState = { status: 'loading', evidence: null } + +export function useFrameReviewEvidence( + sequence: PreviewSequence | null, + actionType: PlaytestActionType | null, + reader: ImageGeometryReader = readImageGeometry, +): FrameReviewEvidenceState { + const sequenceKey = + sequence === null || actionType === null + ? null + : JSON.stringify([ + actionType, + ...sequence.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + rootMotion: frame.rootMotion, + })), + ]) + const cache = useRef(null) + const [resolved, setResolved] = useState({ key: null, value: IDLE_STATE }) + + if (cache.current === null || cache.current.reader !== reader) { + cache.current = { reader, entries: new Map() } + } + + useEffect(() => { + if (sequenceKey === null || actionType === null) { + setResolved({ key: null, value: IDLE_STATE }) + return + } + + const [, ...frameDescriptors] = JSON.parse(sequenceKey) as [ + PlaytestActionType, + ...Array<{ imageUrl: string; rootMotion: PreviewFrame['rootMotion'] }>, + ] + const imageUrls = frameDescriptors.map((frame) => frame.imageUrl) + + const controller = new AbortController() + let active = true + const reads = cache.current?.entries + const inFlight = new Map>() + + setResolved({ key: sequenceKey, value: LOADING_STATE }) + + const results = imageUrls.map((imageUrl) => { + const cached = reads?.get(imageUrl) + if (cached !== undefined) return Promise.resolve(cached) + + const existing = inFlight.get(imageUrl) + if (existing !== undefined) return existing + + const pending = reader(imageUrl, controller.signal) + .catch((): FrameGeometryResult => ({ status: 'unavailable', reason: '图片分析失败' })) + .then((result) => { + if (!controller.signal.aborted && result.status === 'ready') reads?.set(imageUrl, result) + return result + }) + inFlight.set(imageUrl, pending) + return pending + }) + + void Promise.all(results).then((frameResults) => { + if (!active || controller.signal.aborted) return + + setResolved({ + key: sequenceKey, + value: { + status: 'ready', + evidence: buildSequenceEvidence( + frameResults.map((geometry, index) => ({ + geometry, + rootMotion: frameDescriptors[index]?.rootMotion ?? null, + })), + actionType, + ), + }, + }) + }) + + return () => { + active = false + controller.abort() + } + }, [actionType, reader, sequenceKey]) + + if (sequenceKey === null) return IDLE_STATE + return resolved.key === sequenceKey ? resolved.value : LOADING_STATE +} diff --git a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx new file mode 100644 index 0000000..3636cb9 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx @@ -0,0 +1,169 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ActionSelector } from './action-selector' +import { AnimationStage } from './animation-stage' +import { FrameTimeline } from './frame-timeline' +import { PlaybackControls } from './playback-controls' +import type { PreviewAction, PreviewFrame, PreviewSequence } from './model/types' + +const currentFrame: PreviewFrame = { + imageUrl: 'https://cdn.example.test/current.png', + durationMs: 100, + rootMotion: { dx: 12, dy: 5 }, + keyFrame: true, +} + +const nextFrame: PreviewFrame = { + ...currentFrame, + imageUrl: 'https://cdn.example.test/next.png', + keyFrame: false, +} + +const sequence: PreviewSequence = { + direction: 'south', + frames: [currentFrame, nextFrame], +} + +const actions: readonly PreviewAction[] = [ + { + id: 'walk', + name: '行走', + type: 'walk', + fps: 12, + sequences: [sequence], + }, + { + id: 'empty', + name: '空动作', + type: 'idle', + fps: 12, + sequences: [], + }, +] + +afterEach(cleanup) + +describe('playtest visual primitives', () => { + it('renders prop-supplied action names, FPS and actual frame counts while disabling empty actions', () => { + // Catches a selector replacing supplied actions with demo data or permitting a non-playable action. + const onSelectAction = vi.fn() + + render( + , + ) + + expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('12 FPS') + expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('2 帧') + expect((screen.getByRole('button', { name: /空动作/ }) as HTMLButtonElement).disabled).toBe( + true, + ) + + fireEvent.click(screen.getByRole('button', { name: /行走/ })) + expect(onSelectAction).toHaveBeenCalledWith('walk') + }) + + it('uses the real frame URL and reports image failure with the accumulated mirrored transform', () => { + // Catches the stage reading per-frame root motion instead of the accumulated owner state, inverting y incorrectly, or hiding load errors. + render( + , + ) + + const image = screen.getByRole('img', { name: '角色动画预览' }) + expect(image.getAttribute('src')).toBe(currentFrame.imageUrl) + expect(image.getAttribute('style')).toContain('translate(18px, -7px) scaleX(-1)') + expect(screen.getAllByRole('img')).toHaveLength(1) + + fireEvent.error(image) + expect(screen.getByText('当前帧图片加载失败')).toBeTruthy() + }) + + it('reports horizontal travel from the measured stage and actor widths', () => { + const onHorizontalBoundsChange = vi.fn() + const rect = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const width = this.getAttribute('aria-label') === '动画预览舞台' ? 600 : 200 + return { + width, + height: 400, + x: 0, + y: 0, + top: 0, + right: width, + bottom: 400, + left: 0, + toJSON: () => ({}), + } + }) + + render( + , + ) + + fireEvent.load(screen.getByRole('img', { name: '角色动画预览' })) + expect(onHorizontalBoundsChange).toHaveBeenLastCalledWith({ minX: -200, maxX: 200 }) + rect.mockRestore() + }) + + it('surfaces key-frame markers and delegates timeline selection without its own playback behavior', () => { + // Catches a timeline dropping key-frame annotations or mutating playback rather than using its selection callback. + const onSelectFrame = vi.fn() + + render( + , + ) + + expect(screen.getByText('关键帧')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '第 2 帧' })) + expect(onSelectFrame).toHaveBeenCalledWith(1) + }) + + it('only relays playback controller callbacks', () => { + // Catches controls owning local play state instead of reporting user intent to the controller. + const onTogglePlaying = vi.fn() + const onNextFrame = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '播放' })) + fireEvent.click(screen.getByRole('button', { name: '下一帧' })) + expect(onTogglePlaying).toHaveBeenCalledTimes(1) + expect(onNextFrame).toHaveBeenCalledTimes(1) + expect(screen.getByText('A 左行走')).toBeTruthy() + expect(screen.getByText('D 右行走')).toBeTruthy() + expect(screen.getByText('未提供跳跃动作')).toBeTruthy() + expect(screen.getByText('下蹲动作可用')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx new file mode 100644 index 0000000..06fb3c6 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { PreviewFrame } from './model/types' +import type { HorizontalStageBounds, StageOffset } from './stage-motion' + +export interface AnimationStageProps { + currentFrame: PreviewFrame | null + /** Accumulated playback position in world coordinates (positive y is up). */ + motionOffset: StageOffset + mirrored: boolean + showGrid: boolean + showChecker: boolean + onHorizontalBoundsChange?(bounds: HorizontalStageBounds | null): void +} + +export function AnimationStage({ + currentFrame, + motionOffset, + mirrored, + showGrid, + showChecker, + onHorizontalBoundsChange, +}: AnimationStageProps) { + const [failedImageUrl, setFailedImageUrl] = useState(null) + const stageRef = useRef(null) + const imageRef = useRef(null) + + const reportHorizontalBounds = useCallback(() => { + if (onHorizontalBoundsChange === undefined) return + const stage = stageRef.current + const image = imageRef.current + if (stage === null || image === null) { + onHorizontalBoundsChange(null) + return + } + + const stageWidth = stage.getBoundingClientRect().width + const actorWidth = image.getBoundingClientRect().width + if (stageWidth <= 0 || actorWidth <= 0) { + onHorizontalBoundsChange(null) + return + } + const travel = Math.max(0, (stageWidth - actorWidth) / 2) + onHorizontalBoundsChange({ minX: -travel, maxX: travel }) + }, [onHorizontalBoundsChange]) + + useEffect(() => { + setFailedImageUrl(null) + }, [currentFrame?.imageUrl]) + + useEffect(() => { + reportHorizontalBounds() + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', reportHorizontalBounds) + return () => window.removeEventListener('resize', reportHorizontalBounds) + } + + const observer = new ResizeObserver(reportHorizontalBounds) + if (stageRef.current !== null) observer.observe(stageRef.current) + if (imageRef.current !== null) observer.observe(imageRef.current) + return () => observer.disconnect() + }, [currentFrame?.imageUrl, reportHorizontalBounds]) + + const imageFailed = currentFrame !== null && failedImageUrl === currentFrame.imageUrl + + return ( +
    + {showGrid ? ( +
    + ) +} diff --git a/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx new file mode 100644 index 0000000..76d98cf --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx @@ -0,0 +1,106 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewFrame } from '../model/types' +import { AuditPanel } from './audit-panel' + +const frame: PreviewFrame = { + imageUrl: '/walk-03.png', + durationMs: 100, + rootMotion: { dx: 4, dy: 0 }, + keyFrame: false, +} + +afterEach(cleanup) + +describe('AuditPanel', () => { + it('marks the current frame and keeps automatic findings read-only', () => { + const onAdd = vi.fn() + render( + , + ) + + expect(screen.getByText('自动')).toBeTruthy() + expect(screen.getByText('主体接触画布边缘,可能发生裁切')).toBeTruthy() + fireEvent.change(screen.getByLabelText('问题类型'), { + target: { value: 'style_inconsistent' }, + }) + fireEvent.change(screen.getByLabelText('问题说明'), { + target: { value: '衣服颜色跳变' }, + }) + fireEvent.click(screen.getByRole('button', { name: '标记当前帧问题' })) + + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'style_inconsistent', + actionId: 'walk', + direction: 'south', + frameIndex: 2, + imageUrl: '/walk-03.png', + note: '衣服颜色跳变', + }), + ) + }) + + it('edits and removes an existing manual issue', () => { + const onUpdate = vi.fn() + const onRemove = vi.fn() + render( + , + ) + + expect(screen.getByText('人工')).toBeTruthy() + fireEvent.change(screen.getByLabelText('人工问题类型'), { + target: { value: 'motion_direction' }, + }) + fireEvent.change(screen.getByLabelText('人工问题说明'), { + target: { value: '移动方向错误' }, + }) + fireEvent.click(screen.getByRole('button', { name: '删除人工问题' })) + + expect(onUpdate).toHaveBeenCalledWith('manual-1', 'motion_direction', '步幅突然变化') + expect(onUpdate).toHaveBeenCalledWith('manual-1', 'motion_discontinuity', '移动方向错误') + expect(onRemove).toHaveBeenCalledWith('manual-1') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx b/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx new file mode 100644 index 0000000..6241e5d --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react' + +import type { QualityFinding } from '../analysis/sequence-evidence' +import type { PlaytestDirection, PreviewFrame } from '../model/types' +import type { ManualAuditIssue, ManualIssueCategory } from './audit-session' + +const CATEGORY_LABELS: Readonly> = { + subject_cropped: '主体裁切', + transparency: '透明背景异常', + image_unavailable: '空白或加载失败', + duplicate_frame: '重复帧', + motion_discontinuity: '动作抖动或不连续', + motion_direction: '位移或方向错误', + style_inconsistent: '风格不一致', + other: '其他', +} + +let fallbackId = 0 + +function createIssueId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + fallbackId += 1 + return `manual-${Date.now()}-${fallbackId}` +} + +export interface AuditPanelProps { + actionId: string | null + actionName: string | null + direction: PlaytestDirection | null + frameIndex: number + frame: PreviewFrame | null + automaticFindings: readonly QualityFinding[] + issues: readonly ManualAuditIssue[] + onAdd(issue: ManualAuditIssue): void + onUpdate(id: string, category: ManualIssueCategory, note: string): void + onRemove(id: string): void +} + +export function AuditPanel({ + actionId, + actionName, + direction, + frameIndex, + frame, + automaticFindings, + issues, + onAdd, + onUpdate, + onRemove, +}: AuditPanelProps) { + const [category, setCategory] = useState('subject_cropped') + const [note, setNote] = useState('') + const canMark = actionId !== null && direction !== null && frame !== null + + return ( +
    ) } diff --git a/frontend/src/pages/project-detail/index.tsx b/frontend/src/pages/project-detail/index.tsx index cc6ff5c..20c4640 100644 --- a/frontend/src/pages/project-detail/index.tsx +++ b/frontend/src/pages/project-detail/index.tsx @@ -1,9 +1,57 @@ -/** 项目详情。 */ -export function ProjectDetailPage() { +/** 项目详情页:展示项目约束,并把资产与运行记录分成两个明确入口。 */ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { Project, ProjectApis } from '@/entities' + +export function ProjectDetailPage({ apis }: { apis: ProjectApis }) { + const { projectId = '' } = useParams() + const [project, setProject] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId) return + void apis.get(projectId).then( + (value) => active && setProject(value), + (cause) => active && setError(cause instanceof Error ? cause.message : '项目加载失败'), + ) + return () => { + active = false + } + }, [apis, projectId]) + return ( -
    -

    项目详情

    -

    本次只提交模块划分与接口,页面实现进后续 PR。

    +
    +

    PROJECT

    +

    {project?.name ?? '项目详情'}

    + {error && ( +

    + {error} +

    + )} + {project && ( +

    + 画布 {project.spriteSize.width} × {project.spriteSize.height},{project.perspective}, + {project.directionalMovement} +

    + )} +
    ) } diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx index 89cd5ee..e48bb20 100644 --- a/frontend/src/pages/projects/index.tsx +++ b/frontend/src/pages/projects/index.tsx @@ -1,11 +1,63 @@ -/** 项目历史入口;项目和已完成版本的具体列表在项目接口接入后实现。 */ -export function ProjectsPage() { +/** 项目列表页:只组织项目级入口,角色资产由 AssetLibrary 页面负责。 */ +import { useEffect, useState } from 'react' +import { Link } from 'react-router' + +import type { Project, ProjectApis } from '@/entities' + +export function ProjectsPage({ apis }: { apis: ProjectApis }) { + const [projects, setProjects] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + void apis.list().then( + (page) => active && setProjects(page.items), + (cause) => active && setError(cause instanceof Error ? cause.message : '项目加载失败'), + ) + return () => { + active = false + } + }, [apis]) + return ( -
    -

    项目历史

    -

    - 按项目查看已完成版本与其导出记录;具体列表等待 ProjectApis 接入。 -

    +
    +
    +
    +

    PROJECTS

    +

    项目

    +
    + + 新建项目 + +
    + {error && ( +

    + {error} +

    + )} + {!error && projects.length === 0 && ( +

    + 暂无项目。 +

    + )} +
    + {projects.map((project) => ( + +

    {project.name}

    +

    + {project.spriteSize.width} × {project.spriteSize.height} ·{' '} + {project.directionalMovement} +

    + + ))} +
    ) } diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 409d302..71fcac9 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -7,9 +7,8 @@ import { createWorkflowRunStore, type Generation, type GenerationApis, + type GenerationEvent, type GenerationInput, - type TaskApis, - type TaskEvent, type WorkflowRun, type WorkflowStep, } from '@/entities' @@ -20,14 +19,16 @@ import { createQuickStartService } from './service' afterEach(cleanup) interface HarnessOptions { - prepareProject?: (prompt: string) => Promise<{ id: string }> + prepareProject?: ( + prompt: string, + ) => Promise<{ id: string; spriteSize: { width: number; height: number } }> createGeneration?: GenerationApis['create'] nextStepError?: Error } function createHarness(options: HarnessOptions = {}) { const store = createWorkflowRunStore({ storage: null }) - const taskListeners = new Map void>() + const taskListeners = new Map void>() const createGeneration: GenerationApis['create'] = options.createGeneration ?? @@ -41,9 +42,11 @@ function createHarness(options: HarnessOptions = {}) { error: null, }) as Generation) - const taskApis: TaskApis = { + const generationApis: GenerationApis = { + create: createGeneration, get: vi.fn(async (_projectId, taskId) => ({ id: taskId, + projectId: _projectId, type: 'character_template' as const, status: 'pending' as const, result: null, @@ -66,8 +69,7 @@ function createHarness(options: HarnessOptions = {}) { const controller = createWorkflowController({ store, - generationApis: { create: createGeneration }, - taskApis, + generationApis, createId: (scope) => scope === 'run' ? 'run-1' : scope === 'revision' ? 'revision-1' : 'submission-1', now: () => '2026-07-31T03:30:00.000Z', @@ -78,14 +80,15 @@ function createHarness(options: HarnessOptions = {}) { }) } const prepareProject = - options.prepareProject ?? vi.fn(async (_prompt: string) => ({ id: ' project-1 ' })) + options.prepareProject ?? + vi.fn(async (_prompt: string) => ({ id: ' project-1 ', spriteSize: { width: 64, height: 64 } })) const service = createQuickStartService({ controller, prepareProject }) return { prepareProject, service, store, - emit(event: TaskEvent) { + emit(event: GenerationEvent) { const listener = taskListeners.get(`project-1:${event.taskId}`) if (!listener) throw new Error(`Missing listener for task ${event.taskId}`) listener(event) diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 16522ad..79eb2ae 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -9,6 +9,7 @@ import { type WorkflowStep, type WorkflowStepType, } from '@/entities' +import { buildPlaytestPath } from '@/features/publish' import { unavailableQuickStartService, type QuickStartService } from './service' export type { @@ -21,11 +22,8 @@ const STEP_LABELS: Record = { 'character-setup': '角色设定', 'character-template': '角色图', 'template-candidate': '候选选择', - 'action-setup': '动作设定', - 'first-frame': '动作首帧', - 'complete-animation': '完整动画', + 'action-generation': '动作生成', review: '审核', - export: '导出', } const EXAMPLES = [ @@ -42,7 +40,7 @@ const EXAMPLES = [ export interface QuickStartPageProps { /** * 页面测试与后续生产组合可以注入同一份服务实例。 - * 默认实现明确不可用,直到真实 Project / Generation / Task 实现到位。 + * 默认实现明确不可用,直到真实 Project / Character / Generation 实现到位。 */ service?: QuickStartService } @@ -205,6 +203,8 @@ function QuickStartRun({ service, runId }: { service: QuickStartService; runId: const navigate = useNavigate() const [run, setRun] = useState(() => service.getWorkflow(runId)) const [error, setError] = useState(null) + const [selectedCandidate, setSelectedCandidate] = useState(null) + const [actionDescription, setActionDescription] = useState('') useEffect(() => { let active = true @@ -214,15 +214,26 @@ function QuickStartRun({ service, runId }: { service: QuickStartService; runId: if (!current) return const unsubscribe = service.subscribe(runId, (updated) => { - setRun(updated) - setError(null) + if (active) { + setRun(updated) + setError(null) + } }) void service.resume(runId).catch((cause) => { if (active) setError(errorMessage(cause, '恢复生成任务失败')) }) + + // 兜底轮询:异步动作生成完成后 store subscription 可能漏掉,每 3s 刷新一次 + const pollTimer = setInterval(() => { + if (!active) return + const latest = service.getWorkflow(runId) + if (latest) setRun(latest) + }, 3000) + return () => { active = false unsubscribe() + clearInterval(pollTimer) } }, [runId, service]) @@ -255,6 +266,17 @@ function QuickStartRun({ service, runId }: { service: QuickStartService; runId: const candidates = templateStep?.output?.images ?? [] const passedCount = revision.steps.filter((step) => step.status === 'passed').length + const actionStep = revision.steps.find((step) => step.type === 'action-generation') + const actionFrames = + actionStep?.type === 'action-generation' && actionStep.status === 'passed' + ? (actionStep.output?.frames ?? []) + : [] + const reviewStep = revision.steps.find((step) => step.type === 'review') + const canPublish = + actionFrames.length > 0 && (reviewStep?.status === 'active' || reviewStep?.status === 'passed') + const isActionActive = actionStep?.status === 'active' + const isActionFailed = actionStep?.status === 'failed' + async function interrupt() { try { const interrupted = service.interrupt(runId) @@ -264,6 +286,39 @@ function QuickStartRun({ service, runId }: { service: QuickStartService; runId: } } + async function confirmSelection() { + if (!selectedCandidate) return + try { + const updated = await service.confirmCandidate(runId, selectedCandidate, actionDescription) + setRun(updated) + setSelectedCandidate(null) + setActionDescription('') + } catch (cause) { + setError(errorMessage(cause, '确认选择失败')) + } + } + + async function regenerate() { + if (!run?.prompt) return + try { + const newRun = await service.start(run.prompt) + navigate(`/quick-start/${encodeURIComponent(newRun.id)}`) + } catch (cause) { + setError(errorMessage(cause, '重新生成失败')) + } + } + + async function publishToPlaytest() { + await service.approveReview(runId) + // 内存 Map / 持久化引用缺失时(动作生成中或旧运行记录),从后端按项目反查 + const info = service.getCharacterInfo(runId) ?? (await service.resolveCharacterInfo(runId)) + if (info) { + navigate(buildPlaytestPath(info)) + return + } + setError('尚未找到可导出的角色,请等待动作生成完成后再试') + } + return (
    @@ -298,24 +353,94 @@ function QuickStartRun({ service, runId }: { service: QuickStartService; runId:
    - {candidates.length ? ( -
    - {candidates.map((candidate, index) => ( -
    - {`角色图候选 -
    - CANDIDATE {String(index + 1).padStart(2, '0')} -
    -
    + {actionFrames.length > 0 ? ( +
    + {actionFrames.map((frame, index) => ( + {`动作第 ))}
    + ) : isActionActive ? ( +
    +
    + +
    + + 正在生成动作 + + 正在生成 idle 动作帧,请稍候… + + +
    + ) : isActionFailed ? ( +
    + 动作生成失败 + + {typeof actionStep?.error === 'string' ? actionStep.error : '动作生成失败'} + +
    + ) : candidates.length ? ( +
    +
    + {candidates.map((candidate, index) => ( + + ))} +
    +
    + +
    + + {selectedCandidate ? ( + + ) : null} +
    +
    +
    ) : (
    @@ -389,6 +514,17 @@ function QuickStartRun({ service, runId }: { service: QuickStartService; runId: 中断自动制作 ) : null} + {canPublish ? ( + + ) : null} {candidates.length || run.status === 'failed' || run.status === 'interrupted' ? ( -
    -
    -
    +
    +

    QUALITY ISSUES

    +

    问题记录

    +

    仅保留在当前 Playtest 会话

    +
    + +
    +

    自动发现

    + {automaticFindings.length === 0 ? ( +

    当前序列没有自动问题

    + ) : ( +
      + {automaticFindings.map((finding, index) => ( +
    • +
      + {finding.message} + 自动 +
      + + {finding.frameIndex === null ? '整段序列' : `第 ${finding.frameIndex + 1} 帧`} + +
    • + ))} +
    + )} +
    + +
    +

    + 标记当前帧{actionName === null ? '' : ` · ${actionName} #${frameIndex + 1}`} +

    + + + + + ` + } else if (isPassed) { + bodyHtml = ` +
    角色设定${statusLabel}
    +

    已提交角色设定

    + ` + } else { + bodyHtml = `
    角色设定${statusLabel}
    ` + } + break + + case 'character-template': + if (isActive) { + bodyHtml = ` +
    生成角色图生成中…
    +
    ${Array.from({ length: 81 }, (_, i) => { + const x = i % 9 - 4, y = Math.floor(i / 9) - 4 + const ring = Math.max(Math.abs(x), Math.abs(y)) + return `` + }).join('')}
    + 正在生成 6 张候选母版… + ` + } else if (isPassed) { + bodyHtml = ` +
    生成角色图${statusLabel}
    +

    角色图已生成,下一步确认候选。

    + ` + } else { + bodyHtml = `
    生成角色图${statusLabel}
    ` + } + break + + case 'template-candidate': + if (isActive) { + bodyHtml = ` +
    确认候选${statusLabel}
    +

    从候选中选择一张作为身份母版。

    +
    + + + + + + +
    + + ` + } else if (isPassed) { + bodyHtml = ` +
    确认候选${statusLabel}
    +

    已确认身份母版。

    + ` + } else { + bodyHtml = `
    确认候选${statusLabel}
    ` + } + break + + case 'action-setup': + if (isActive) { + bodyHtml = ` +
    动作设定${statusLabel}
    +

    选择要生成的动作类型。

    +
    + + +
    + ` + } else if (isPassed) { + bodyHtml = ` +
    动作设定${statusLabel}
    +

    已选择动作类型。

    + ` + } else { + bodyHtml = `
    动作设定${statusLabel}
    ` + } + break + + case 'first-frame': + if (isActive) { + bodyHtml = ` +
    生成首帧生成中…
    +
    正在生成动作首帧…
    + ` + } else if (isPassed) { + bodyHtml = ` +
    生成首帧${statusLabel}
    +

    首帧已生成,确认后开始完整动画。

    + + ` + } else { + bodyHtml = `
    生成首帧${statusLabel}
    ` + } + break + + case 'complete-animation': + if (isActive) { + bodyHtml = ` +
    生成动画生成中…
    +
    正在生成 8 帧动画…
    +
    + ${Array.from({ length: 8 }, (_, i) => `${String(i + 1).padStart(2, '0')}`).join('')} +
    + ` + } else if (isPassed) { + bodyHtml = ` +
    生成动画${statusLabel}
    +
    + ${Array.from({ length: 8 }, (_, i) => `${String(i + 1).padStart(2, '0')}`).join('')} +
    + + ` + } else { + bodyHtml = `
    生成动画${statusLabel}
    ` + } + break + + case 'review': + if (isActive) { + bodyHtml = ` +
    审核${statusLabel}
    +

    检查所有动作是否符合预期。

    + + ` + } else if (isPassed) { + bodyHtml = ` +
    审核${statusLabel}
    +

    已通过审核。

    + ` + } else { + bodyHtml = `
    审核${statusLabel}
    ` + } + break + + case 'export': { + const character = workflowRunToCharacter(run) + const playtestUrl = character ? buildPlaytestUrl(character) : '/playtest/demo' + const exportReady = canExportToPlaytest(run) + + if (isActive) { + bodyHtml = ` +
    导出${statusLabel}
    +

    ${exportReady ? '资产已就绪,选择导出方式。' : '等待上游步骤完成。'}

    +
    + + 导出到 Playtest 预览 + 在预览台检查动作播放与循环效果 + + +
    + ` + } else if (isPassed) { + bodyHtml = ` +
    导出已完成
    +

    全部完成!

    +
    + + 导出到 Playtest 预览 + 在预览台检查动作播放与循环效果 + + +
    + ` + } else { + bodyHtml = `
    导出${statusLabel}
    ` + } + break } - }, - [dragState, panState], - ) - const handlePointerUp = useCallback( - (event: ReactPointerEvent) => { - setDragState(null) - setPanState(null) - document.body.style.userSelect = '' - viewportRef.current?.releasePointerCapture(event.pointerId) - }, - [], - ) + default: + bodyHtml = `
    ${meta.title}${statusLabel}
    ` + } + } - const handleWheel = useCallback((event: ReactWheelEvent) => { - event.preventDefault() - const delta = event.ctrlKey || event.metaKey ? -event.deltaY * 0.0014 : 0 - if (delta !== 0) setView((prev) => ({ ...prev, scale: Math.min(1.2, Math.max(0.5, prev.scale + delta)) })) - else setView((prev) => ({ ...prev, x: prev.x - event.deltaX, y: prev.y - event.deltaY })) - }, []) - - const getPortPosition = useCallback( - (nodeId: WorkflowNodeType, port: 'input' | 'output') => { - const node = nodes.find((n) => n.id === nodeId) - if (!node) return { x: 0, y: 0 } - return port === 'output' ? { x: node.x + 292, y: node.y + 60 } : { x: node.x, y: node.y + 60 } - }, - [nodes], - ) + const hasInput = step.type !== 'character-setup' + const hasOutput = step.type !== 'export' + const outputEnabled = isPassed || isActive + + return ` +
    + ${hasInput ? '' : ''} +
    + ${meta.eyebrow}

    ${meta.title}

    + +
    +
    ${bodyHtml}
    + ${hasInput ? `` : ''} + ${hasOutput ? `` : ''} +
    + ` +} + +export function WorkflowCanvas({ controller, run, unavailableReason, onStepAction }: WorkflowCanvasProps) { + const rootRef = useRef(null) + const revision = getCurrentRevision(run) + + // 渲染连线 + 同步连接门控 + useEffect(() => { + if (!revision) return + controller.renderWires() - const renderWires = useCallback(() => { - const wires: JSX.Element[] = [] - connections.forEach((key) => { - const [from, to] = key.split(':') as WorkflowNodeType[] - wires.push() + // 连接门控:禁用 data-connection-required 按钮直到连接建立 + rootRef.current?.querySelectorAll('[data-connection-required]').forEach((el) => { + const requirements = (el as HTMLElement).dataset.connectionRequired!.split(',') + const connected = requirements.every((req) => controller.connections.has(req.trim())) + ;(el as HTMLButtonElement).disabled = !connected + el.closest('[data-node-id]')?.classList.toggle('is-waiting-connection', !connected) }) - ALLOWED_CONNECTIONS.forEach(({ from, to }) => { - if (hasConnection(from, to)) return - const fromNode = nodes.find((n) => n.id === from) - if (!fromNode?.outputEnabled) return - wires.push() + }, [controller, revision]) + + // 绑定交互事件 + useEffect(() => { + if (!rootRef.current || !revision) return + controller.attach(rootRef.current) + const root = rootRef.current + + // 跳过步骤按钮 + root.querySelectorAll('[data-skip-step]').forEach((btn) => { + btn.addEventListener('click', () => { + const stepEl = btn.closest('[data-node-id]') as HTMLElement + const stepType = stepEl?.dataset.nodeId + if (stepType) onStepAction?.(stepType, 'skip') + }) }) - return wires - }, [connections, nodes, getPortPosition, hasConnection]) - const zoomBy = useCallback((d: number) => setView((prev) => ({ ...prev, scale: Math.min(1.2, Math.max(0.5, prev.scale + d)) })), []) - const resetLayout = useCallback(() => setView({ x: 80, y: 120, scale: 0.85 }), []) + return () => controller.detach() + }, [controller, revision, onStepAction]) - return ( -
    -
    -
    - {renderWires()} - - {nodes.map((node) => ( -
    onNodeSelect?.(node.id)} - > - {node.hasInput && ( -
    - ))} + if (!revision) { + return ( +
    +
    +
    + + 无法加载工作流 + 找不到当前版本 + +
    +
    + ) + } + + const visibleSteps = revision.steps.filter((step, index) => { + if (step.status !== 'locked') return true + return index <= 1 // 只显示前两步(character-setup 和 character-template) + }) + return ( +
    +
    +
    ) } + +function getHintText(revision: WorkflowRevision): string { + const activeStep = revision.steps.find((s) => s.status === 'active') + if (!activeStep) return '所有步骤已完成' + const meta = NODE_TITLES[activeStep.type] + return meta ? `当前:${meta.title}` : `当前:${activeStep.type}` +} diff --git a/frontend/src/pages/workflow-editor/workflow-editor.css b/frontend/src/pages/workflow-editor/workflow-editor.css new file mode 100644 index 0000000..db16d35 --- /dev/null +++ b/frontend/src/pages/workflow-editor/workflow-editor.css @@ -0,0 +1,195 @@ +/* 工作流编辑器样式 */ +:root { + --wf-white: #dfe3df; + --wf-ink: #171817; + --wf-muted: #747973; + --wf-line: #e2e3de; + --wf-accent: #263f2d; + --wf-accent-soft: #e4ebe2; +} + +.workflow-app { min-height: 100vh; background: var(--wf-white); } + +/* Studio Bar */ +.studio-bar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; min-height: 52px; padding: 0 clamp(16px,3vw,48px); border-bottom: 1px solid rgba(31,43,34,.1); background: rgba(223,227,223,.88); backdrop-filter: blur(18px); } +.studio-bar__left, .studio-bar__right { display: flex; align-items: center; gap: 12px; } +.studio-bar__brand { display: inline-flex; gap: 8px; align-items: center; color: var(--wf-ink); text-decoration: none; } +.studio-bar__brand .product-brand__mark { display: block; width: 28px; height: 28px; background: var(--wf-ink); mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231b1b1b'/%3E%3Cpath d='M16 5 27 16 16 27 5 16Z' fill='%23ff6b35'/%3E%3Ccircle cx='16' cy='16' r='4' fill='%23fff4e8'/%3E%3C/svg%3E") center/contain no-repeat; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231b1b1b'/%3E%3Cpath d='M16 5 27 16 16 27 5 16Z' fill='%23ff6b35'/%3E%3Ccircle cx='16' cy='16' r='4' fill='%23fff4e8'/%3E%3C/svg%3E") center/contain no-repeat; } +.studio-bar__brand b { font-family: Georgia,"Times New Roman",serif; font-size: 16px; font-weight: 700; } +.studio-bar__project { display: flex; flex-direction: column; gap: 1px; padding-left: 12px; border-left: 1px solid var(--wf-line); } +.studio-bar__project b { font-size: 12px; font-weight: 600; } +.studio-bar__project small { font-size: 10px; color: var(--wf-muted); } +.studio-bar__nav { display: flex; gap: 4px; } +.studio-bar__nav a { display: inline-flex; min-height: 32px; align-items: center; padding: 0 10px; border-radius: 8px; color: #5e635d; font-size: 11px; font-weight: 600; text-decoration: none; } +.studio-bar__nav a:hover { background: rgba(255,255,255,.6); } +.studio-bar__nav a.is-active { color: var(--wf-accent); background: var(--wf-accent-soft); } +.studio-bar__actions { display: flex; gap: 4px; } +.studio-bar__actions button { min-height: 32px; padding: 0 10px; border: 1px solid var(--wf-line); border-radius: 8px; background: rgba(255,255,255,.6); font-size: 11px; font-weight: 600; cursor: pointer; } + +/* Production Canvas */ +.production-canvas-workspace { height: calc(100svh - 52px); min-height: 660px; overflow: hidden; background: var(--wf-white); } + +/* Project Setup */ +.project-setup { display: grid; width: 100%; min-height: 100%; place-items: center; padding: 40px; background: radial-gradient(circle at 78% 18%,rgba(88,111,94,.08),transparent 28%),linear-gradient(150deg,#f8f7f3,#ecece7 74%); } +.project-setup__form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; max-width: 560px; width: 100%; } +.project-setup__form-head { grid-column: 1 / -1; } +.project-setup__form-head h2 { margin: 0; font: 500 32px/1.1 Georgia,"Songti SC",serif; } +.project-setup__wide { grid-column: 1 / -1; } +.project-setup__form label { display: grid; gap: 6px; } +.project-setup__form label span { font-size: 11px; font-weight: 600; } +.project-setup__form input, .project-setup__form select, .project-setup__form textarea { padding: 10px 12px; border: 1px solid rgba(27,38,30,.15); border-radius: 10px; background: rgba(255,255,255,.7); font-size: 13px; font-family: inherit; } +.project-setup__form footer { grid-column: 1 / -1; display: flex; justify-content: flex-end; padding-top: 8px; } +.button--primary { padding: 12px 24px; border: 1px solid var(--wf-accent); border-radius: 12px; background: var(--wf-accent); color: #f2f5f1; font-size: 13px; font-weight: 600; cursor: pointer; } +.button--primary:hover { background: #1d3025; } + +/* Setup Messages */ +.setup-notice { margin: 12px 40px; padding: 12px 16px; border: 1px solid #c7a967; border-radius: 12px; background: #f4eddc; color: #67552e; font-size: 13px; } +.setup-error { margin: 12px 40px; padding: 12px 16px; border-radius: 12px; background: #311b19; color: #ffd3cc; font-size: 13px; } +.setup-loading { margin: 12px 40px; color: #687069; font-size: 13px; } + +/* Error View */ +.error-view { padding: 80px 40px; } +.error-view .overline { font-family: ui-monospace,monospace; font-size: 10px; font-weight: 700; letter-spacing: .16em; color: #687069; } +.error-view h1 { margin: 16px 0 12px; font: 500 40px/1.1 Georgia,"Songti SC",serif; } +.error-view p { color: #687069; font-size: 14px; } + +/* Studio Mode Gateway */ +.studio-mode-gateway { position: relative; display: grid; width: 100%; min-height: 100%; grid-template-rows: auto 1fr auto; gap: 34px; padding: 112px clamp(30px,6vw,104px) 42px; overflow: hidden; background: radial-gradient(circle at 78% 18%,rgba(88,111,94,.08),transparent 28%),linear-gradient(150deg,#f8f7f3,#ecece7 74%); animation: studio-mode-enter 520ms cubic-bezier(.22,1,.36,1) both; } +@keyframes studio-mode-enter { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } +.studio-mode-gateway::before { position: absolute; inset: 0; content: ""; opacity: .28; background-image: radial-gradient(circle,rgba(27,31,28,.24) 1px,transparent 1.2px); background-size: 24px 24px; pointer-events: none; } +.studio-mode-gateway > * { position: relative; z-index: 1; } +.studio-mode-gateway__header { display: grid; max-width: 720px; gap: 11px; } +.studio-mode-gateway__header .overline { color: #6e756f; font: 700 8px/1 monospace; letter-spacing: .18em; } +.studio-mode-gateway__header h1 { margin: 0; font: 500 clamp(34px,4.2vw,62px)/1.04 Georgia,"Songti SC",serif; letter-spacing: -.035em; } +.studio-mode-gateway__header p { max-width: 610px; margin: 0; color: #666b67; font-size: 12px; line-height: 1.75; } +.studio-mode-gateway__choices { display: grid; min-height: 360px; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; } +.studio-mode-card { position: relative; display: grid; min-height: 100%; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr auto; gap: 24px 30px; padding: clamp(26px,3.5vw,52px); overflow: hidden; border: 1px solid rgba(20,20,20,.12); border-radius: 24px; color: #1a1b1a; background: rgba(250,250,247,.78); box-shadow: 0 26px 64px rgba(28,35,30,.09),inset 0 1px rgba(255,255,255,.72); text-align: left; cursor: pointer; transition: border-color 240ms ease,box-shadow 240ms ease,transform 240ms cubic-bezier(.22,1,.36,1); text-decoration: none; } +.studio-mode-card:hover { border-color: rgba(40,69,48,.36); box-shadow: 0 34px 80px rgba(25,36,28,.15),inset 0 1px #fff; transform: translateY(-6px); } +.studio-mode-card__eyebrow { font: 700 9px/1 monospace; letter-spacing: .14em; color: var(--wf-muted); text-transform: uppercase; } +.studio-mode-card__index { font: 500 48px/1 Georgia,serif; color: rgba(23,24,23,.08); } +.studio-mode-card__copy { display: grid; gap: 8px; align-content: start; } +.studio-mode-card__copy small { font: 700 8px/1 monospace; letter-spacing: .14em; color: var(--wf-muted); } +.studio-mode-card__copy b { font: 500 20px/1.2 Georgia,"Songti SC",serif; color: var(--wf-ink); } +.studio-mode-card__copy p { margin: 0; font-size: 12px; color: #666b67; line-height: 1.6; } +.studio-mode-card__action { align-self: end; font: 600 12px/1 system-ui,sans-serif; color: var(--wf-accent); } +.studio-mode-gateway__note { display: flex; gap: 10px; align-items: center; padding: 12px 0; border-top: 1px solid rgba(20,20,20,.08); color: #666b67; font-size: 11px; } +.studio-mode-gateway__note i { width: 24px; height: 24px; border-radius: 50%; background: rgba(38,63,45,.08); } +.studio-mode-gateway__note b { display: block; font-size: 11px; font-weight: 600; color: var(--wf-ink); } +.studio-mode-gateway__note small { font-size: 10px; } + +/* Node Graph */ +.node-graph-workspace { position: relative; height: 100%; overflow: hidden; } +.node-canvas { position: absolute; inset: 0; overflow: hidden; cursor: grab; touch-action: none; user-select: none; background-color: var(--wf-white); background-image: radial-gradient(circle,rgba(46,62,50,.18) 1px,transparent 1.25px); background-size: 21px 21px; } +.node-canvas.is-panning { cursor: grabbing; } +.node-surface { position: absolute; top: 0; left: 0; width: 3000px; height: 1000px; transform-origin: 0 0; will-change: transform; } +.node-wires { position: absolute; z-index: 1; top: 0; left: 0; width: 3000px; height: 1000px; overflow: visible; pointer-events: none; } +.node-wire { fill: none; stroke-width: 3; stroke: #69866f; vector-effect: non-scaling-stroke; filter: drop-shadow(0 2px 2px rgba(38,55,43,.18)); } +.node-wire.is-suggested { stroke: #b8b4aa; stroke-dasharray: 7 6; opacity: .5; } + +/* Graph Node */ +.graph-node { position: absolute; z-index: 3; width: 292px; overflow: visible; border: 1px solid rgba(27,38,30,.2); border-radius: 12px; background: rgba(249,250,247,.98); box-shadow: 0 14px 36px rgba(30,39,32,.13); cursor: default; } +.graph-node.has-input { border-color: rgba(66,99,75,.48); } +.graph-node.is-dragging { z-index: 8; box-shadow: 0 23px 54px rgba(26,37,29,.2); } +.graph-node > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-radius: 11px 11px 0 0; color: #f2f5f1; background: #26352b; cursor: move; user-select: none; } +.graph-node > header span { display: grid; gap: 2px; } +.graph-node > header small { color: #aebcb1; font-size: 6px; font-weight: 800; letter-spacing: .12em; } +.graph-node > header h2 { margin: 0; font-size: 10px; font-weight: 720; } +.graph-node > header > i { display: flex; gap: 3px; } +.graph-node > header > i b { width: 3px; height: 3px; border-radius: 50%; background: #89998c; } +.graph-node__body { display: grid; gap: 10px; padding: 13px; } + +/* Graph Port */ +.graph-port { position: absolute; top: 50%; width: 12px; height: 12px; border: 3px solid #8fa292; border-radius: 50%; background: #f2f4f0; transform: translateY(-50%); cursor: pointer; z-index: 5; } +.graph-port--input { left: -6px; } +.graph-port--output { right: -6px; } +.graph-port[data-enabled="false"] { border-color: #c8c2b7; background: #ece9e1; cursor: not-allowed; } +.graph-port[data-enabled="true"]:hover { transform: translateY(-50%) scale(1.3); border-color: var(--wf-accent); background: var(--wf-accent-soft); } + +/* Node Status */ +.node-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-radius: 6px; background: rgba(27,38,30,.04); } +.node-status span { font-size: 8px; color: #5a5f5a; } +.node-status b { font-size: 8px; font-weight: 700; } +.node-status--active b { color: var(--wf-accent); } +.node-status--passed b { color: #3d6b4a; } +.node-status--failed b { color: #8b332a; } + +/* Node API Notice */ +.node-api-notice { display: grid; gap: 8px; padding: 10px; border: 1px dashed #c7a967; border-radius: 8px; background: #f4eddc; } +.node-api-notice p { margin: 0; font-size: 10px; color: #67552e; line-height: 1.5; } + +/* Node Desc */ +.node-desc { margin: 0; font-size: 10px; color: #5a5f5a; line-height: 1.5; } + +/* Node Action */ +.node-action { display: flex; flex-direction: column; gap: 2px; padding: 8px 12px; border: 1px solid var(--wf-accent); border-radius: 8px; background: var(--wf-accent); color: #f2f5f1; font-size: 10px; font-weight: 700; cursor: pointer; text-decoration: none; text-align: left; } +.node-action:hover { background: #1d3025; } +.node-action strong { font-size: 11px; } +.node-action small { font-size: 9px; opacity: .8; } +.node-action--secondary { background: transparent; color: var(--wf-accent); } +.node-action--secondary:hover { background: var(--wf-accent-soft); } +.node-action.is-disabled, .node-action[aria-disabled="true"] { opacity: .4; cursor: not-allowed; pointer-events: none; } + +/* Node Action List */ +.node-action-list { display: grid; gap: 6px; } +.node-action-option { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border: 1px solid rgba(27,38,30,.15); border-radius: 8px; background: rgba(255,255,255,.6); cursor: pointer; text-align: left; } +.node-action-option:hover { border-color: rgba(38,63,45,.3); background: rgba(255,255,255,.9); } +.node-action-option strong { font-size: 11px; font-weight: 600; } +.node-action-option small { font-size: 9px; color: var(--wf-muted); } + +/* Node Candidate List */ +.node-candidate-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } +.node-candidate { display: flex; flex-direction: column; align-items: center; padding: 10px 8px; border: 1px solid rgba(27,38,30,.15); border-radius: 8px; background: rgba(255,255,255,.6); cursor: pointer; } +.node-candidate:hover { border-color: rgba(38,63,45,.3); background: rgba(255,255,255,.9); } +.node-candidate small { font-size: 9px; font-weight: 600; } + +/* Node Export Options */ +.node-export-options { display: grid; gap: 8px; } + +/* Node Canvas Hint */ +.node-canvas-hint { position: absolute; bottom: 16px; left: 50%; z-index: 10; display: flex; min-height: 44px; padding: 8px 16px; transform: translateX(-50%); border: 1px solid rgba(31,43,34,.1); border-radius: 12px; background: rgba(249,250,247,.88); backdrop-filter: blur(18px); } +.node-canvas-hint__copy { display: flex; flex-direction: column; gap: 2px; } +.node-canvas-hint__copy b { font-size: 11px; font-weight: 600; } +.node-canvas-hint__copy > span { font-size: 10px; color: var(--wf-muted); } + +/* Node Zoom */ +.node-zoom { position: absolute; bottom: 16px; right: 16px; z-index: 10; display: flex; gap: 4px; align-items: center; padding: 4px; border: 1px solid rgba(31,43,34,.1); border-radius: 10px; background: rgba(249,250,247,.88); } +.node-zoom button { display: grid; width: 28px; height: 28px; place-items: center; border: none; border-radius: 6px; background: transparent; font-size: 14px; cursor: pointer; } +.node-zoom button:hover { background: rgba(255,255,255,.8); } +.node-zoom output { min-width: 36px; text-align: center; font-family: ui-monospace,monospace; font-size: 10px; } + +/* Node Generation Dots — 9×9 grid with ring-based animation */ +.node-generation__dots { display: grid; grid-template-columns: repeat(9, 6px); gap: 2px; justify-content: center; padding: 8px; } +.node-generation__dots i { width: 6px; height: 6px; border-radius: 50%; background: rgba(38,63,45,.08); } +.node-generation__dots i.dot-ring-0 { background: var(--wf-accent); animation: dot-pulse 1.2s ease infinite; } +.node-generation__dots i.dot-ring-1 { background: rgba(38,63,45,.25); animation: dot-pulse 1.2s ease .1s infinite; } +.node-generation__dots i.dot-ring-2 { background: rgba(38,63,45,.15); animation: dot-pulse 1.2s ease .2s infinite; } +@keyframes dot-pulse { 0%,100% { opacity: .3; transform: scale(.8); } 50% { opacity: 1; transform: scale(1.2); } } + +/* Node Frame Strip — per-frame arrival */ +.node-frame-strip { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; } +.node-frame-strip span { display: flex; flex-direction: column; align-items: center; gap: 2px; } +.node-frame-strip span small { font-size: 7px; color: var(--wf-muted); } +.node-frame-strip .is-arrived { animation: frame-arrive .4s ease; } +.node-frame-strip .is-pending i { display: block; width: 28px; height: 28px; border-radius: 4px; background: rgba(27,38,30,.06); animation: frame-pending 1.5s ease infinite; } +@keyframes frame-arrive { from { opacity: 0; transform: scale(.8); } to { opacity: 1; transform: scale(1); } } +@keyframes frame-pending { 0%,100% { opacity: .2; } 50% { opacity: .5; } } + +/* Node Connect Surface — click-to-confirm overlay */ +.graph-node__connect-surface { display: none; position: absolute; inset: 0; z-index: 10; border: 2px dashed var(--wf-accent); border-radius: 12px; background: rgba(38,63,45,.06); cursor: pointer; place-items: center; } +.graph-node.is-waiting-connection .graph-node__connect-surface { display: grid; } +.graph-node__connect-surface span { font-size: 10px; font-weight: 600; color: var(--wf-accent); } + +/* Node Running State */ +.graph-node.is-running { box-shadow: 0 0 0 2px rgba(87,121,96,.18), 0 18px 48px rgba(30,45,34,.18); } + +/* Connection Flash Animation */ +.graph-node.is-connection-committed { animation: connection-flash .9s ease; } +@keyframes connection-flash { 0% { box-shadow: 0 0 0 0 rgba(38,63,45,.3); } 50% { box-shadow: 0 0 0 8px rgba(38,63,45,.15); } 100% { box-shadow: 0 14px 36px rgba(30,39,32,.13); } } + +/* Wire Draw Animation */ +.node-wire.is-new { animation: wire-draw .6s ease forwards; stroke-dasharray: 1; stroke-dashoffset: 1; } +@keyframes wire-draw { to { stroke-dashoffset: 0; } } + +/* Port Highlight */ +.graph-port.is-connectable { border-color: var(--wf-accent); background: var(--wf-accent-soft); box-shadow: 0 0 0 3px rgba(38,63,45,.15); animation: port-pulse 1s ease infinite; } +@keyframes port-pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(38,63,45,.15); } 50% { box-shadow: 0 0 0 6px rgba(38,63,45,.08); } } diff --git a/frontend/src/pages/workflow-editor/workflow-shell.css b/frontend/src/pages/workflow-editor/workflow-shell.css deleted file mode 100644 index 01aef42..0000000 --- a/frontend/src/pages/workflow-editor/workflow-shell.css +++ /dev/null @@ -1,13393 +0,0 @@ -:root { - color-scheme: light; - --workspace-white: #dfe3df; - --ink: #171817; - --ink-soft: #343733; - --muted: #747973; - --faint: #a2a69f; - --line: #e2e3de; - --line-strong: #cfd1ca; - --paper: var(--workspace-white); - --surface: var(--workspace-white); - --surface-soft: #efefe9; - --accent: #263f2d; - --accent-soft: #e4ebe2; - --warm: #c89755; - --shadow: 0 24px 70px rgba(25, 28, 23, 0.08); - --radius: 20px; - font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -* { - box-sizing: border-box; -} - -html { - min-width: 320px; - background: var(--paper); - scroll-behavior: smooth; -} - -body { - min-height: 100vh; - margin: 0; - color: var(--ink); - background: var(--paper); - -webkit-font-smoothing: antialiased; -} - -button, a { - color: inherit; - font: inherit; - -webkit-tap-highlight-color: transparent; -} - -button { - border: 0; -} - -a { - text-decoration: none; -} - -.workflow-app { - min-height: 100vh; -} - -.product-header { - position: sticky; - z-index: 20; - top: 0; - display: grid; - grid-template-columns: minmax(250px, 1fr) auto minmax(250px, 1fr); - align-items: center; - min-height: 76px; - padding: 0 clamp(22px, 4vw, 64px); - border-bottom: 1px solid rgba(255, 255, 255, .54); - background: linear-gradient(180deg, rgba(223, 227, 223, .72), rgba(223, 227, 223, .5)); - box-shadow: 0 10px 30px rgba(27, 36, 29, .055), inset 0 -1px 0 rgba(31, 43, 35, .05); - -webkit-backdrop-filter: blur(26px) saturate(145%); - backdrop-filter: blur(26px) saturate(145%); -} - -.product-brand { - display: inline-flex; - gap: 11px; - align-items: center; - width: fit-content; - view-transition-name: windup-nav-left; -} - -.product-brand__mark { - display: block; - width: 36px; - height: 36px; - background: var(--ink); - mask: url('./brand/windup-mark.svg') center / contain no-repeat; - -webkit-mask: url('./brand/windup-mark.svg') center / contain no-repeat; -} - -.product-brand__name { - font-family: Georgia, "Times New Roman", serif; - font-size: 20px; - font-weight: 700; - letter-spacing: -0.03em; -} - -.product-brand__tag { - margin-left: 3px; - padding-left: 14px; - border-left: 1px solid var(--line-strong); - color: var(--muted); - font-size: 12px; -} - -.product-nav { - display: flex; - gap: 5px; - align-items: center; - view-transition-name: windup-nav-right; -} - -.product-nav a { - position: relative; - display: inline-flex; - min-height: 38px; - align-items: center; - padding: 0 14px; - border-radius: 10px; - color: #5e635d; - font-size: 13px; - font-weight: 600; -} - -.product-nav a:hover, .product-nav a:focus-visible { - color: var(--ink); - background: rgba(255, 255, 255, 0.75); - outline: none; -} - -.product-nav a.is-active { - color: var(--accent); - background: var(--accent-soft); -} - -.product-account { - display: flex; - justify-content: flex-end; - gap: 12px; - align-items: center; -} - -.service-pill { - display: inline-flex; - gap: 8px; - align-items: center; - min-height: 34px; - padding: 0 12px; - border: 1px solid var(--line); - border-radius: 999px; - color: var(--muted); - background: rgba(255, 255, 255, 0.72); - font-size: 11px; -} - -.service-pill::before { - width: 7px; - height: 7px; - border-radius: 50%; - background: #6f8b71; - box-shadow: 0 0 0 4px rgba(111, 139, 113, 0.12); - content: ''; -} - -.account-avatar { - display: grid; - width: 36px; - height: 36px; - place-items: center; - border: 1px solid var(--line-strong); - border-radius: 50%; - color: var(--ink); - background: var(--surface); - cursor: default; - font-family: Georgia, serif; - font-weight: 700; -} - -.product-home { - overflow: hidden; -} - -.product-hero, .continue-project, .home-section, .home-closing { - width: min(1420px, calc(100% - 48px)); - margin-right: auto; - margin-left: auto; -} - -.product-hero { - display: grid; - grid-template-columns: minmax(420px, 0.86fr) minmax(540px, 1.14fr); - gap: clamp(36px, 5vw, 82px); - align-items: center; - min-height: calc(100vh - 76px); - padding: 68px 0 84px; -} - -.hero-copy { - position: relative; - z-index: 2; -} - -.overline { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.15em; -} - -.hero-copy h1 { - max-width: 690px; - margin: 22px 0 24px; - font-family: Georgia, "Songti SC", "Times New Roman", serif; - font-size: clamp(50px, 5.5vw, 84px); - font-weight: 500; - letter-spacing: -0.055em; - line-height: 1.04; - outline: none; -} - -.hero-copy > p { - max-width: 600px; - margin: 0; - color: var(--muted); - font-size: clamp(15px, 1.35vw, 18px); - line-height: 1.8; -} - -.hero-actions, .workspace-actions { - display: flex; - flex-wrap: wrap; - gap: 11px; -} - -.hero-actions { - margin-top: 34px; -} - -.button { - display: inline-flex; - min-height: 46px; - align-items: center; - justify-content: center; - padding: 0 19px; - border: 1px solid var(--line-strong); - border-radius: 11px; - font-size: 13px; - font-weight: 700; - transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; -} - -.button:hover, .button:focus-visible { - border-color: var(--ink); - outline: none; - transform: translateY(-1px); -} - -.button--primary { - border-color: var(--ink); - color: #fff; - background: var(--ink); - box-shadow: 0 10px 24px rgba(23, 24, 23, 0.14); -} - -.button--primary:hover, .button--primary:focus-visible { - box-shadow: 0 14px 30px rgba(23, 24, 23, 0.2); -} - -.button--ghost { - background: rgba(255, 255, 255, 0.74); -} - -.hero-proof { - display: flex; - gap: 24px; - margin-top: 48px; - padding-top: 22px; - border-top: 1px solid var(--line); -} - -.hero-proof > span { - display: grid; - gap: 4px; -} - -.hero-proof b { - color: var(--faint); - font-family: Georgia, serif; - font-size: 13px; - font-weight: 500; -} - -.hero-proof small { - color: var(--ink-soft); - font-size: 12px; - font-weight: 650; -} - -.hero-visual { - position: relative; - min-height: 620px; - margin: 0; - overflow: hidden; - border-radius: 30px; - background: #08101c; - box-shadow: 0 38px 90px rgba(13, 19, 24, 0.22); -} - -.hero-visual__scene, .hero-visual__shade { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.hero-visual__scene { - object-fit: cover; - object-position: center; - image-rendering: pixelated; - transform: scale(1.01); -} - -.hero-visual__shade { - background: linear-gradient(180deg, rgba(4, 10, 18, 0.08), rgba(4, 10, 18, 0.14) 48%, rgba(4, 10, 18, 0.88)), linear-gradient(90deg, rgba(4, 10, 18, 0.16), transparent 52%); -} - -.hero-visual__character { - position: absolute; - bottom: 9%; - left: 48%; - width: clamp(115px, 13vw, 178px); - image-rendering: pixelated; - filter: drop-shadow(0 18px 16px rgba(0, 0, 0, 0.58)); - transform: translateX(-50%); -} - -.hero-visual__caption { - position: absolute; - bottom: 31px; - left: 34px; - display: grid; - gap: 5px; - color: #fff; -} - -.hero-visual__caption span, .hero-visual__status span { - color: rgba(255, 255, 255, 0.58); - font-family: ui-monospace, monospace; - font-size: 9px; - letter-spacing: 0.13em; -} - -.hero-visual__caption strong { - font-family: Georgia, "Songti SC", serif; - font-size: 24px; - font-weight: 500; -} - -.hero-visual__caption small { - color: rgba(255, 255, 255, 0.7); - font-size: 11px; -} - -.hero-visual__status { - position: absolute; - display: grid; - gap: 4px; - min-width: 108px; - padding: 13px 15px; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 13px; - color: #fff; - background: rgba(7, 13, 20, 0.62); - backdrop-filter: blur(12px); -} - -.hero-visual__status b { - font-family: Georgia, serif; - font-size: 18px; - font-weight: 500; -} - -.status-a { - top: 28px; - right: 28px; -} - -.status-b { - top: 112px; - right: 28px; -} - -.continue-project { - display: grid; - grid-template-columns: minmax(280px, 0.8fr) minmax(420px, 1.15fr) auto; - gap: 42px; - align-items: center; - padding: 28px 32px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(255, 255, 255, 0.83); - box-shadow: var(--shadow); - transition: border-color 160ms ease, transform 160ms ease; -} - -.continue-project:hover, .continue-project:focus-visible { - border-color: var(--line-strong); - outline: none; - transform: translateY(-2px); -} - -.continue-project__identity { - display: flex; - gap: 15px; - align-items: center; -} - -.project-emblem { - display: grid; - width: 50px; - height: 50px; - flex: 0 0 auto; - place-items: center; - border-radius: 14px; - color: #fff; - background: var(--accent); - font-family: "Songti SC", Georgia, serif; - font-size: 20px; -} - -.continue-project__identity small, .continue-project__identity p { - color: var(--muted); - font-size: 11px; -} - -.continue-project__identity h3 { - margin: 4px 0; - font-family: Georgia, "Songti SC", serif; - font-size: 20px; - font-weight: 500; -} - -.continue-project__identity p { - margin: 0; -} - -.project-progress { - display: grid; - grid-template-columns: repeat(4, 1fr); -} - -.project-progress span { - position: relative; - display: grid; - gap: 8px; - justify-items: center; - color: var(--muted); - font-size: 10px; -} - -.project-progress span::before { - position: absolute; - z-index: 0; - top: 5px; - right: 50%; - left: -50%; - height: 1px; - content: ''; - background: var(--line-strong); -} - -.project-progress span:first-child::before { - display: none; -} - -.project-progress i { - position: relative; - z-index: 1; - width: 11px; - height: 11px; - border: 2px solid var(--surface); - border-radius: 50%; - background: var(--line-strong); - box-shadow: 0 0 0 1px var(--line-strong); -} - -.project-progress i.is-done { - background: var(--accent); - box-shadow: 0 0 0 1px var(--accent); -} - -.project-progress i.is-current { - background: var(--warm); - box-shadow: 0 0 0 1px var(--warm), 0 0 0 5px rgba(200, 151, 85, 0.14); -} - -.continue-project__arrow { - font-size: 12px; -} - -.home-section { - padding: 140px 0 120px; -} - -.home-section__heading { - max-width: 780px; - margin-bottom: 45px; -} - -.home-section__heading h2, .home-closing h2 { - margin: 14px 0 15px; - font-family: Georgia, "Songti SC", serif; - font-size: clamp(34px, 4.2vw, 58px); - font-weight: 500; - letter-spacing: -0.045em; - line-height: 1.12; -} - -.home-section__heading p { - margin: 0; - color: var(--muted); - line-height: 1.7; -} - -.capability-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; -} - -.capability-card { - display: flex; - min-height: 310px; - flex-direction: column; - padding: 28px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--surface); - transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; -} - -.capability-card:hover, .capability-card:focus-visible { - border-color: var(--line-strong); - outline: none; - box-shadow: var(--shadow); - transform: translateY(-3px); -} - -.capability-card > span { - color: var(--faint); - font-family: Georgia, serif; - font-size: 15px; -} - -.capability-card h3 { - margin: 75px 0 14px; - font-family: Georgia, "Songti SC", serif; - font-size: 25px; - font-weight: 500; -} - -.capability-card p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.75; -} - -.capability-card b { - margin-top: auto; - font-size: 12px; -} - -.home-closing { - display: flex; - justify-content: space-between; - gap: 32px; - align-items: flex-end; - margin-bottom: 48px; - padding: 54px 60px; - border-radius: 28px; - color: #fff; - background: var(--ink); -} - -.home-closing .overline { - color: rgba(255, 255, 255, 0.55); -} - -.home-closing h2 { - max-width: 750px; - margin-bottom: 0; -} - -.home-closing .button--primary { - flex: 0 0 auto; - border-color: #fff; - color: var(--ink); - background: var(--workspace-white); -} - -.home-closing__actions { - display: flex; - gap: 10px; - align-items: center; -} - -.home-closing__actions .button--ghost { - border-color: rgba(255, 255, 255, 0.28); - color: #fff; - background: rgba(255, 255, 255, 0.06); -} - -.product-workspace { - width: min(1280px, calc(100% - 48px)); - min-height: calc(100vh - 76px); - margin: 0 auto; - padding: 32px 0 64px; -} - -.breadcrumbs { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin: 0; - padding: 0; - list-style: none; - color: var(--muted); - font-size: 11px; -} - -.breadcrumbs li:not(:last-child)::after { - margin-left: 7px; - content: '/'; - color: var(--line-strong); -} - -.breadcrumbs a:hover { - color: var(--ink); -} - -.workspace-heading { - display: flex; - justify-content: space-between; - gap: 32px; - align-items: flex-start; - margin: 46px 0 30px; -} - -.workspace-heading > div { - max-width: 780px; -} - -.workspace-heading h1 { - margin: 11px 0 10px; - font-family: Georgia, "Songti SC", serif; - font-size: clamp(37px, 4.6vw, 58px); - font-weight: 500; - letter-spacing: -0.045em; - outline: none; -} - -.workspace-heading p { - margin: 0; - color: var(--muted); - font-size: 14px; - line-height: 1.7; -} - -.text-action { - flex: 0 0 auto; - padding: 9px 0; - border-bottom: 1px solid var(--line-strong); - color: var(--muted); - font-size: 12px; -} - -.journey { - display: grid; - grid-template-columns: repeat(6, 1fr); - margin: 0 0 22px; - padding: 0; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 14px; - list-style: none; - background: rgba(255, 255, 255, 0.62); -} - -.journey li { - display: grid; - grid-template-columns: auto 1fr; - gap: 9px; - align-items: center; - min-height: 54px; - padding: 0 16px; - border-right: 1px solid var(--line); - color: var(--faint); -} - -.journey li:last-child { - border-right: 0; -} - -.journey span { - font-family: Georgia, serif; - font-size: 11px; -} - -.journey strong { - font-size: 11px; -} - -.journey .is-done { - color: var(--accent); - background: rgba(228, 235, 226, 0.65); -} - -.journey .is-current { - color: var(--ink); - background: var(--surface); -} - -.project-hub { - display: grid; - grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.7fr); - gap: 18px; -} - -.project-spotlight, .project-next, .asset-library, .choice-card, .form-architecture, .media-workbench, .record-list, .selection-grid, .playtest-layout { - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--surface); - box-shadow: var(--shadow); -} - -.project-spotlight { - position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr) 210px; - min-height: 430px; - overflow: hidden; -} - -.project-spotlight__copy { - z-index: 1; - padding: 42px; -} - -.status-chip { - display: inline-flex; - min-height: 26px; - align-items: center; - padding: 0 10px; - border-radius: 999px; - color: var(--accent); - background: var(--accent-soft); - font-size: 10px; - font-weight: 700; -} - -.project-spotlight h2 { - margin: 48px 0 12px; - font-family: Georgia, "Songti SC", serif; - font-size: 40px; - font-weight: 500; -} - -.project-spotlight p, .project-next p, .asset-workspace__heading p, .master-overview__copy p { - color: var(--muted); - font-size: 13px; - line-height: 1.7; -} - -.project-spotlight .button { - margin-top: 25px; -} - -.project-spotlight__art { - display: grid; - place-items: center; - background: linear-gradient(rgba(38, 63, 45, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(38, 63, 45, 0.05) 1px, transparent 1px), var(--surface-soft); - background-size: 22px 22px; -} - -.project-spotlight__art img { - width: 150px; - image-rendering: pixelated; -} - -.project-stats { - position: absolute; - right: 210px; - bottom: 0; - left: 0; - display: grid; - grid-template-columns: repeat(3, 1fr); - margin: 0; - border-top: 1px solid var(--line); -} - -.project-stats div { - display: grid; - gap: 6px; - padding: 18px 42px; - border-right: 1px solid var(--line); -} - -.project-stats dt { - color: var(--muted); - font-size: 10px; -} - -.project-stats dd { - margin: 0; - font-family: Georgia, serif; - font-size: 23px; -} - -.project-next { - display: flex; - flex-direction: column; - padding: 32px; -} - -.project-next h3 { - margin: 70px 0 12px; - font-family: Georgia, "Songti SC", serif; - font-size: 24px; - font-weight: 500; - line-height: 1.25; -} - -.project-next a { - margin-top: auto; - font-size: 12px; - font-weight: 700; -} - -.asset-library { - display: grid; - grid-template-columns: 250px minmax(0, 1fr); - min-height: 640px; - overflow: hidden; -} - -.asset-browser { - display: flex; - flex-direction: column; - padding: 26px 18px; - border-right: 1px solid var(--line); - background: #f1f1ec; -} - -.asset-browser header { - padding: 4px 8px 20px; - border-bottom: 1px solid var(--line); -} - -.asset-browser h2 { - margin: 8px 0 0; - font-family: Georgia, "Songti SC", serif; - font-size: 20px; - font-weight: 500; -} - -.asset-browser nav { - display: grid; - gap: 7px; - padding-top: 20px; -} - -.asset-browser nav a { - display: grid; - gap: 4px; - padding: 12px 13px; - border-radius: 10px; -} - -.asset-browser nav a:hover, .asset-browser nav a:focus-visible, .asset-browser nav a.is-active { - background: var(--surface); - outline: none; -} - -.asset-browser nav a.is-open { - color: var(--accent); -} - -.asset-browser nav span { - color: var(--muted); - font-size: 9px; -} - -.asset-browser nav strong { - font-size: 12px; -} - -.asset-browser__create { - margin-top: auto; - padding: 13px; - border-top: 1px solid var(--line); - font-size: 12px; - font-weight: 700; -} - -.asset-workspace { - padding: 34px; -} - -.asset-workspace__heading, .shelf-heading { - display: flex; - justify-content: space-between; - gap: 24px; - align-items: flex-start; -} - -.asset-workspace__heading h2 { - margin: 8px 0 0; - font-family: Georgia, "Songti SC", serif; - font-size: 34px; - font-weight: 500; -} - -.asset-workspace__heading p { - margin: 6px 0 0; -} - -.master-overview { - display: grid; - grid-template-columns: 210px 1fr; - min-height: 240px; - margin-top: 25px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 16px; -} - -.master-overview__preview { - display: grid; - place-items: center; - background: linear-gradient(45deg, #e5e5df 25%, transparent 25%), linear-gradient(-45deg, #e5e5df 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5df 75%), linear-gradient(-45deg, transparent 75%, #e5e5df 75%), #f2f2ed; - background-position: 0 0, 0 9px, 9px -9px, -9px 0; - background-size: 18px 18px; -} - -.master-overview__preview img { - width: 145px; - image-rendering: pixelated; -} - -.master-overview__copy { - padding: 30px; -} - -.master-overview__copy h3 { - margin: 18px 0 7px; - font-family: Georgia, "Songti SC", serif; - font-size: 23px; - font-weight: 500; -} - -.master-overview__copy p { - max-width: 580px; - margin: 0; -} - -.coverage-row { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin-top: 25px; -} - -.coverage-row span { - padding: 6px 9px; - border-radius: 7px; - color: var(--accent); - background: var(--accent-soft); - font-size: 10px; - font-weight: 700; -} - -.shelf-heading { - align-items: flex-end; - margin: 34px 0 15px; -} - -.shelf-heading h3 { - margin: 7px 0 0; - font-family: Georgia, "Songti SC", serif; - font-size: 22px; - font-weight: 500; -} - -.shelf-heading a { - font-size: 11px; - font-weight: 700; -} - -.asset-shelf { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; -} - -.asset-card { - overflow: hidden; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface); -} - -.asset-card:hover, .asset-card:focus-visible { - border-color: var(--line-strong); - outline: none; -} - -.asset-card__preview { - display: grid; - min-height: 145px; - place-items: center; - background: var(--surface-soft); -} - -.asset-card__preview img { - width: 96px; - image-rendering: pixelated; -} - -.asset-card__copy { - display: grid; - gap: 5px; - padding: 13px 14px; -} - -.asset-card__copy strong { - font-size: 12px; -} - -.asset-card__copy small { - color: var(--muted); - font-size: 10px; -} - -.choice-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; -} - -.choice-card { - display: flex; - min-height: 390px; - flex-direction: column; - padding: 30px; - transition: transform 160ms ease, border-color 160ms ease; -} - -.choice-card:hover, .choice-card:focus-visible { - border-color: var(--ink); - outline: none; - transform: translateY(-3px); -} - -.choice-card > span { - color: var(--faint); - font-family: Georgia, serif; -} - -.choice-card h2 { - margin: 105px 0 15px; - font-family: Georgia, "Songti SC", serif; - font-size: 28px; - font-weight: 500; -} - -.choice-card p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.75; -} - -.choice-card b { - margin-top: auto; - font-size: 12px; -} - -.form-architecture { - overflow: hidden; -} - -.form-block { - display: grid; - grid-template-columns: 55px minmax(0, 1fr); - gap: 22px; - align-items: start; - padding: 26px 30px; - border-bottom: 1px solid var(--line); -} - -.form-block:last-child { - border-bottom: 0; -} - -.form-block > span { - padding-top: 4px; - color: var(--faint); - font-family: Georgia, serif; - font-size: 12px; -} - -.form-block > div { - display: grid; - gap: 11px; -} - -.form-block label { - font-size: 13px; - font-weight: 700; -} - -.input-shell { - display: flex; - min-height: 48px; - align-items: center; - padding: 0 14px; - border: 1px solid var(--line); - border-radius: 10px; - color: var(--faint); - background: var(--paper); - font-size: 12px; -} - -.segmented-field { - display: flex; - width: fit-content; - gap: 4px; - padding: 4px; - border-radius: 10px; - background: var(--surface-soft); -} - -.segmented-field button { - min-height: 35px; - padding: 0 13px; - border-radius: 7px; - color: var(--muted); - background: transparent; - font-size: 11px; -} - -.segmented-field button:first-child { - color: var(--ink); - background: var(--surface); - box-shadow: 0 3px 8px rgba(23, 24, 23, 0.08); -} - -.media-workbench { - display: grid; - grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.65fr); - min-height: 540px; - overflow: hidden; -} - -.media-stage { - display: grid; - grid-template-rows: auto 1fr; - padding: 25px; - border-right: 1px solid var(--line); -} - -.media-stage header { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: flex-end; - padding-bottom: 18px; -} - -.media-stage h2, .inspection-panel h3 { - margin: 7px 0 0; - font-family: Georgia, "Songti SC", serif; - font-size: 21px; - font-weight: 500; -} - -.media-stage__canvas { - position: relative; - display: grid; - min-height: 410px; - place-items: center; - border-radius: 14px; - background: linear-gradient(rgba(38, 63, 45, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(38, 63, 45, 0.05) 1px, transparent 1px), var(--surface-soft); - background-size: 22px 22px; -} - -.media-stage__canvas img { - width: 180px; - image-rendering: pixelated; - filter: drop-shadow(0 16px 15px rgba(23, 24, 23, 0.18)); -} - -.media-stage__canvas span { - position: absolute; - bottom: 17px; - left: 18px; - color: var(--muted); - font-size: 10px; -} - -.inspection-panel { - padding: 28px; -} - -.inspection-panel header { - padding-bottom: 22px; - border-bottom: 1px solid var(--line); -} - -.inspection-row { - display: grid; - grid-template-columns: 34px minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - min-height: 70px; - border-bottom: 1px solid var(--line); -} - -.inspection-row > span { - color: var(--faint); - font-family: Georgia, serif; - font-size: 11px; -} - -.inspection-row strong { - font-size: 12px; -} - -.inspection-row small { - color: var(--muted); - font-size: 9px; -} - -.record-list { - overflow: hidden; -} - -.record-list section { - display: grid; - grid-template-columns: 58px minmax(0, 1fr) auto; - gap: 18px; - align-items: center; - min-height: 92px; - padding: 0 28px; - border-bottom: 1px solid var(--line); -} - -.record-list section:last-child { - border-bottom: 0; -} - -.record-list section.is-current { - background: rgba(228, 235, 226, 0.5); -} - -.record-list section > span { - color: var(--faint); - font-family: Georgia, serif; - font-size: 12px; -} - -.record-list section div { - display: grid; - gap: 6px; -} - -.record-list strong { - font-size: 13px; -} - -.record-list small, .record-list b { - color: var(--muted); - font-size: 10px; -} - -.selection-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1px; - overflow: hidden; - background: var(--line); -} - -.selection-grid button { - display: grid; - grid-template-columns: 46px minmax(0, 1fr) auto; - gap: 14px; - align-items: center; - min-height: 108px; - padding: 0 24px; - color: var(--ink); - background: var(--surface); - text-align: left; -} - -.selection-grid button.is-selected { - background: var(--accent-soft); -} - -.selection-grid button > span { - color: var(--faint); - font-family: Georgia, serif; - font-size: 12px; -} - -.selection-grid strong { - font-size: 13px; -} - -.selection-grid small { - color: var(--muted); - font-size: 10px; -} - -.playtest-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 250px; - min-height: 590px; - overflow: hidden; -} - -.playtest-stage { - position: relative; - overflow: hidden; - background: #08101c; -} - -.playtest-stage__scene { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - image-rendering: pixelated; -} - -.playtest-stage__character { - position: absolute; - bottom: 14%; - left: 45%; - width: 130px; - image-rendering: pixelated; - filter: drop-shadow(0 13px 13px rgba(0, 0, 0, 0.65)); -} - -.playtest-label { - position: absolute; - top: 20px; - left: 20px; - padding: 8px 10px; - border-radius: 8px; - color: #fff; - background: rgba(7, 13, 20, 0.72); - font-family: ui-monospace, monospace; - font-size: 9px; - letter-spacing: 0.13em; -} - -.playtest-controls { - padding: 20px; -} - -.playtest-controls div { - display: grid; - grid-template-columns: 38px 1fr; - gap: 8px; - align-items: center; - min-height: 72px; - border-bottom: 1px solid var(--line); -} - -.playtest-controls span { - color: var(--faint); - font-family: Georgia, serif; - font-size: 11px; -} - -.playtest-controls strong { - font-size: 12px; -} - -.workspace-actions { - margin-top: 22px; -} - -.workspace-footer { - display: flex; - justify-content: space-between; - gap: 20px; - align-items: center; - margin-top: 46px; - padding-top: 18px; - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 11px; -} - -.workspace-footer a { - color: var(--ink); - font-weight: 700; -} - -@media (max-width: 1120px) { - .product-header { - grid-template-columns: auto 1fr auto; - } - - .product-brand__tag, .service-pill { - display: none; - } - - .product-nav { - justify-self: center; - } - - .product-hero { - grid-template-columns: 0.82fr 1.18fr; - gap: 34px; - } - - .hero-visual { - min-height: 540px; - } - - .continue-project { - grid-template-columns: minmax(260px, 1fr) minmax(380px, 1fr); - } - - .continue-project__arrow { - display: none; - } - - .asset-library { - grid-template-columns: 215px 1fr; - } - -} - -@media (max-width: 900px) { - .product-header { - grid-template-columns: 1fr auto; - min-height: 68px; - } - - .product-nav { - position: fixed; - z-index: 30; - right: 12px; - bottom: 12px; - left: 12px; - justify-content: space-around; - padding: 7px; - border: 1px solid var(--line); - border-radius: 14px; - background: rgba(255, 255, 255, 0.92); - box-shadow: 0 15px 35px rgba(23, 24, 23, 0.15); - backdrop-filter: blur(20px); - } - - .product-nav a { - min-height: 36px; - padding: 0 10px; - font-size: 11px; - } - - .product-hero { - grid-template-columns: 1fr; - padding-top: 72px; - } - - .hero-copy h1 { - max-width: 760px; - } - - .hero-visual { - min-height: 570px; - } - - .continue-project, .project-hub, .asset-library, .media-workbench, .playtest-layout { - grid-template-columns: 1fr; - } - - .continue-project { - gap: 28px; - } - - .capability-grid, .choice-grid { - grid-template-columns: 1fr; - } - - .capability-card, .choice-card { - min-height: 260px; - } - - .capability-card h3, .choice-card h2 { - margin-top: 52px; - } - - .home-closing { - align-items: flex-start; - flex-direction: column; - } - - .journey { - grid-template-columns: repeat(3, 1fr); - } - - .journey li:nth-child(3) { - border-right: 0; - } - - .journey li:nth-child(-n + 3) { - border-bottom: 1px solid var(--line); - } - - .project-spotlight { - grid-template-columns: 1fr 180px; - } - - .project-stats { - right: 180px; - } - - .asset-browser { - display: none; - } - - .media-stage { - border-right: 0; - border-bottom: 1px solid var(--line); - } - - .playtest-stage { - min-height: 500px; - } - -} - -@media (max-width: 620px) { - .product-header { - padding: 0 18px; - } - - .product-brand__name { - font-size: 18px; - } - - .product-account { - gap: 0; - } - - .product-hero, .continue-project, .home-section, .home-closing, .product-workspace { - width: min(100% - 28px, 1420px); - } - - .hero-copy h1 { - font-size: 45px; - } - - .hero-copy > p { - font-size: 15px; - } - - .hero-proof { - gap: 14px; - } - - .hero-visual { - min-height: 470px; - border-radius: 21px; - } - - .hero-visual__caption { - bottom: 23px; - left: 23px; - } - - .hero-visual__status { - min-width: 93px; - } - - .status-a, .status-b { - right: 16px; - } - - .status-a { - top: 16px; - } - - .status-b { - top: 92px; - } - - .continue-project { - padding: 24px; - } - - .project-progress { - grid-template-columns: repeat(2, 1fr); - row-gap: 22px; - } - - .project-progress span:nth-child(3)::before { - display: none; - } - - .home-section { - padding: 95px 0 80px; - } - - .home-closing { - padding: 36px 28px; - } - - .product-nav a { - padding: 0 7px; - font-size: 10px; - } - - .workspace-heading { - display: grid; - margin-top: 34px; - } - - .workspace-heading h1 { - font-size: 39px; - } - - .journey { - grid-template-columns: repeat(2, 1fr); - } - - .journey li:nth-child(2n) { - border-right: 0; - } - - .journey li:nth-child(-n + 4) { - border-bottom: 1px solid var(--line); - } - - .project-spotlight { - grid-template-columns: 1fr; - min-height: 570px; - } - - .project-spotlight__copy { - padding: 28px; - } - - .project-spotlight__art { - min-height: 190px; - } - - .project-stats { - right: 0; - } - - .project-stats div { - padding: 15px; - } - - .asset-workspace { - padding: 22px; - } - - .asset-workspace__heading { - align-items: flex-start; - flex-direction: column; - } - - .master-overview { - grid-template-columns: 1fr; - } - - .master-overview__preview { - min-height: 210px; - } - - .asset-shelf, .selection-grid { - grid-template-columns: 1fr; - } - - .form-block { - grid-template-columns: 34px 1fr; - padding: 22px 18px; - } - - .media-stage { - padding: 17px; - } - - .media-stage__canvas { - min-height: 350px; - } - - .workspace-footer { - align-items: flex-start; - flex-direction: column; - } - -} - -/* Split floating controls for the creation workspace only */ -.studio-bar { - position: fixed; - z-index: 60; - top: 14px; - right: 0; - left: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 0 18px; - pointer-events: none; -} - -.studio-bar__left, .studio-bar__right { - display: flex; - min-height: 58px; - align-items: center; - gap: 12px; - padding: 7px 9px; - border: 1px solid rgba(23, 24, 23, .14); - border-radius: 12px; - background: var(--workspace-white); - box-shadow: none; - -webkit-backdrop-filter: none; - backdrop-filter: none; - pointer-events: auto; -} - -.studio-bar__left { - min-width: min(420px, 42vw); - padding-left: 14px; - view-transition-name: windup-nav-left; -} - -.studio-bar__right { - margin-left: auto; - view-transition-name: windup-nav-right; -} - -.studio-bar__brand { - display: flex; - gap: 8px; - align-items: center; - padding-right: 16px; - border-right: 1px solid rgba(45, 59, 49, .12); - color: #1c231e; -} - -.studio-bar__brand .product-brand__mark { - width: 27px; - height: 27px; -} - -.studio-bar__brand b { - font: 700 16px Georgia, "Times New Roman", serif; -} - -.studio-bar__project { - display: grid; - min-width: 0; - gap: 2px; -} - -.studio-bar__project b { - overflow: hidden; - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.studio-bar__project small { - color: #737d75; - font-size: 8px; -} - -.studio-bar__nav, .studio-bar__actions { - display: flex; - gap: 3px; - align-items: center; -} - -.studio-bar__nav a, .studio-bar__actions button { - display: inline-flex; - min-height: 34px; - align-items: center; - padding: 0 10px; - border-radius: 9px; - color: #5b655d; - background: transparent; - font-size: 9px; - font-weight: 700; - white-space: nowrap; -} - -.studio-bar__actions button i { - display: grid; - min-width: 16px; - height: 16px; - margin-left: 6px; - place-items: center; - border-radius: 999px; - color: #45614d; - background: rgba(206, 221, 210, .78); - font-size: 7px; - font-style: normal; -} - -.studio-bar__nav a:hover, .studio-bar__actions button:hover { - color: #26372c; - background: rgba(231, 238, 232, .72); -} - -.studio-bar__nav a.is-active { - color: #284331; - background: rgba(220, 233, 223, .82); -} - -.studio-bar__actions { - padding-left: 10px; - border-left: 1px solid rgba(45, 59, 49, .12); -} - -.workflow-library-layer { - position: fixed; - z-index: 90; - inset: 0; - display: grid; - place-items: center; - padding: 80px 24px 24px; - background: rgba(24, 30, 26, .2); - -webkit-backdrop-filter: blur(12px) saturate(110%); - backdrop-filter: blur(12px) saturate(110%); - animation: workflow-library-veil 260ms ease both; -} - -.workflow-library-panel { - display: grid; - width: min(720px, 100%); - max-height: min(720px, calc(100svh - 120px)); - overflow: hidden; - border: 1px solid rgba(255, 255, 255, .74); - border-radius: 20px; - background: linear-gradient(180deg, rgba(250, 252, 249, .86), rgba(240, 244, 240, .76)); - box-shadow: 0 32px 90px rgba(21, 31, 24, .24), inset 0 1px 0 rgba(255, 255, 255, .72); - -webkit-backdrop-filter: blur(32px) saturate(145%); - backdrop-filter: blur(32px) saturate(145%); - animation: workflow-library-enter 420ms cubic-bezier(.22, 1, .36, 1) both; -} - -.workflow-library-panel > header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 28px; - padding: 24px 26px 20px; - border-bottom: 1px solid rgba(45, 59, 49, .1); -} - -.workflow-library-panel > header div { - display: grid; - gap: 5px; -} - -.workflow-library-panel > header h2 { - margin: 0; - font: 500 25px/1.15 Georgia, "Songti SC", serif; -} - -.workflow-library-panel > header p { - margin: 0; - color: #747e76; - font-size: 9px; - line-height: 1.6; -} - -.workflow-library-panel > header > button { - display: grid; - width: 34px; - height: 34px; - flex: 0 0 auto; - place-items: center; - border-radius: 9px; - color: #59635b; - background: rgba(225, 232, 226, .76); - font-size: 18px; -} - -.workflow-library-list { - display: grid; - gap: 10px; - padding: 16px; - overflow: auto; -} - -.workflow-library-list article { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 14px 20px; - padding: 16px; - border: 1px solid rgba(48, 66, 53, .12); - border-radius: 13px; - background: rgba(250, 252, 249, .64); -} - -.workflow-library-list article > header { - display: flex; - grid-column: 1 / -1; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.workflow-library-list article > header span { - display: grid; - gap: 4px; -} - -.workflow-library-list article > header b { - font-size: 11px; -} - -.workflow-library-list article > header small { - color: #808981; - font-size: 7px; -} - -.workflow-library-list article > header i { - padding: 6px 8px; - border-radius: 999px; - color: #496351; - background: #e2ece4; - font-size: 7px; - font-style: normal; - font-weight: 750; -} - -.workflow-library-list dl { - display: grid; - grid-template-columns: repeat(3, minmax(90px, 1fr)); - gap: 8px; - margin: 0; -} - -.workflow-library-list dl div { - display: grid; - gap: 4px; - padding: 9px 10px; - border-radius: 8px; - background: rgba(231, 236, 231, .76); -} - -.workflow-library-list dt { - color: #858e87; - font-size: 6px; -} - -.workflow-library-list dd { - margin: 0; - font-size: 8px; - font-weight: 750; -} - -.workflow-library-list article > button { - align-self: end; - min-height: 36px; - padding: 0 13px; - border-radius: 9px; - color: #fff; - background: #35583f; - font-size: 8px; - font-weight: 750; -} - -.workflow-library-empty { - display: grid; - min-height: 220px; - place-content: center; - gap: 8px; - padding: 36px; - text-align: center; -} - -.workflow-library-empty b { - font: 500 18px Georgia, "Songti SC", serif; -} - -.workflow-library-empty p { - max-width: 390px; - margin: 0; - color: #7a837b; - font-size: 9px; - line-height: 1.7; -} - -.workflow-library-panel > footer { - padding: 12px 20px; - border-top: 1px solid rgba(45, 59, 49, .1); - color: #4f6a57; - font-size: 8px; -} - -@keyframes workflow-library-veil { - from { - opacity: 0; - } - -} - -@keyframes workflow-library-enter { - from { - opacity: 0; - filter: blur(9px); - transform: translateY(18px) scale(.96); - } - -} - -.workflow-app[data-route-id='demoBuilder'] .node-canvas { - padding-top: 0; -} - -.workflow-app[data-route-id='demoBuilder'] .production-canvas-workspace { - height: 100svh; - min-height: 660px; -} - -::view-transition-group(windup-nav-left), ::view-transition-group(windup-nav-right) { - z-index: 100; - animation-duration: 760ms; - animation-timing-function: cubic-bezier(.22, 1, .36, 1); -} - -::view-transition-old(windup-nav-left), ::view-transition-old(windup-nav-right) { - animation: navigation-surface-out 420ms cubic-bezier(.4, 0, .2, 1) both; -} - -::view-transition-new(windup-nav-left), ::view-transition-new(windup-nav-right) { - animation: navigation-surface-in 720ms cubic-bezier(.22, 1, .36, 1) both; -} - -::view-transition-old(root) { - animation: route-surface-out 360ms ease both; -} - -::view-transition-new(root) { - animation: route-surface-in 620ms cubic-bezier(.22, 1, .36, 1) both; -} - -@keyframes navigation-surface-out { - from { - opacity: 1; - filter: blur(0); - } - - to { - opacity: 0; - filter: blur(8px); - } - -} - -@keyframes navigation-surface-in { - from { - opacity: 0; - filter: blur(10px); - clip-path: inset(12% 8% round 20px); - } - - to { - opacity: 1; - filter: blur(0); - clip-path: inset(0 round 0); - } - -} - -@keyframes route-surface-out { - to { - opacity: .42; - filter: blur(4px); - } - -} - -@keyframes route-surface-in { - from { - opacity: .25; - filter: blur(6px); - } - -} - -@media (max-width: 900px) { - .studio-bar { - gap: 10px; - } - - .studio-bar__left { - min-width: 0; - } - - .studio-bar__nav { - display: none; - } - - .studio-bar__actions button { - padding: 0 7px; - } - -} - -@media (max-width: 620px) { - .studio-bar__left { - padding-right: 12px; - } - - .studio-bar__project small { - display: none; - } - - .studio-bar__project { - display: none; - } - - .studio-bar__brand { - padding-right: 0; - border-right: 0; - } - - .studio-bar__actions button:nth-child(2) { - display: none; - } - -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } - -} - -/* Full-screen node graph */ -.production-canvas-workspace { - height: calc(100svh - 68px); - min-height: 660px; - overflow: hidden; - background: var(--workspace-white); -} - -.node-graph-workspace { - position: relative; - height: 100%; - overflow: hidden; - color: #202520; - background: var(--workspace-white); -} - -.node-graph-toolbar { - position: absolute; - z-index: 20; - top: 16px; - left: 50%; - display: flex; - width: min(760px, calc(100% - 40px)); - min-height: 48px; - align-items: center; - justify-content: space-between; - gap: 18px; - padding: 7px 8px 7px 16px; - transform: translateX(-50%); - border: 1px solid rgba(31, 43, 34, 0.13); - border-radius: 13px; - background: rgba(249, 250, 247, 0.88); - box-shadow: 0 10px 35px rgba(26, 35, 29, 0.11); - backdrop-filter: blur(18px); -} - -.node-graph-toolbar > span { - display: grid; - min-width: 0; - gap: 2px; -} - -.node-graph-toolbar b { - overflow: hidden; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.node-graph-toolbar small { - color: #7b827b; - font-size: 7px; - letter-spacing: 0.04em; -} - -.node-graph-toolbar > div { - display: flex; - gap: 3px; -} - -.node-graph-toolbar button { - height: 32px; - padding: 0 10px; - border-radius: 8px; - color: #586159; - background: transparent; - font-size: 8px; - font-weight: 700; -} - -.node-graph-toolbar button:hover { - color: #273b2d; - background: #e7ece7; -} - -.node-canvas { - position: absolute; - inset: 0; - overflow: hidden; - cursor: grab; - touch-action: none; - background-color: var(--workspace-white); - background-image: radial-gradient(circle, rgba(46, 62, 50, 0.18) 1px, transparent 1.25px); - background-size: 21px 21px; -} - -.node-canvas.is-panning { - cursor: grabbing; -} - -.node-surface { - position: absolute; - top: 0; - left: 0; - width: 2220px; - height: 900px; - transform-origin: 0 0; - will-change: transform; -} - -.node-wires { - position: absolute; - z-index: 1; - top: 0; - left: 0; - width: 2220px; - height: 900px; - overflow: visible; - pointer-events: none; -} - -.node-wire { - fill: none; - stroke-width: 3; - vector-effect: non-scaling-stroke; -} - -.node-wire.is-connected { - stroke: #69866f; - filter: drop-shadow(0 2px 2px rgba(38, 55, 43, 0.18)); -} - -.node-wire.is-drafting { - stroke: #46614d; - stroke-dasharray: 7 6; -} - -.graph-node { - position: absolute; - z-index: 3; - width: 292px; - overflow: visible; - border: 1px solid rgba(27, 38, 30, 0.2); - border-radius: 12px; - background: rgba(249, 250, 247, 0.98); - box-shadow: 0 14px 36px rgba(30, 39, 32, 0.13), 0 2px 5px rgba(30, 39, 32, 0.08); - cursor: default; -} - -.graph-node.has-input { - border-color: rgba(66, 99, 75, 0.48); -} - -.graph-node.is-dragging { - z-index: 8; - box-shadow: 0 23px 54px rgba(26, 37, 29, 0.2); -} - -.graph-node.is-running { - box-shadow: 0 0 0 2px rgba(87, 121, 96, 0.18), 0 18px 48px rgba(30, 45, 34, 0.18); -} - -.graph-node > header { - display: flex; - min-height: 48px; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - border-radius: 11px 11px 0 0; - color: #f2f5f1; - background: #26352b; - cursor: move; - user-select: none; -} - -.graph-node > header span { - display: grid; - gap: 2px; -} - -.graph-node > header small { - color: #aebcb1; - font-size: 6px; - font-weight: 800; - letter-spacing: 0.12em; -} - -.graph-node > header h2 { - margin: 0; - font-size: 10px; - font-weight: 720; -} - -.graph-node > header > i { - display: flex; - gap: 3px; -} - -.graph-node > header > i b { - width: 3px; - height: 3px; - border-radius: 50%; - background: #89998c; -} - -.graph-node__body { - display: grid; - gap: 10px; - padding: 13px; -} - -.graph-node__body > p { - margin: 0; - color: #707770; - font-size: 8px; - line-height: 1.55; -} - -.graph-port { - position: absolute; - z-index: 9; - top: 32px; - width: 16px; - height: 16px; - padding: 0; - border: 3px solid #f5f7f4; - border-radius: 50%; - background: #68836e; - box-shadow: 0 0 0 1px #435849; - cursor: crosshair; -} - -.graph-port--input { - left: -8px; -} - -.graph-port--output { - right: -8px; -} - -.graph-port[data-enabled="false"] { - border-color: #f5f7f4; - background: #b9beb9; - box-shadow: 0 0 0 1px #8d948e; - cursor: not-allowed; -} - -.graph-port[data-enabled="true"]:hover { - transform: scale(1.22); - background: #3e6749; -} - -.node-status { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 8px; - border-bottom: 1px solid #e0e4df; -} - -.node-status span { - color: #7a817a; - font-size: 7px; -} - -.node-status b { - color: #535b54; - font-size: 7px; -} - -.node-status--confirmed b { - color: #3e6a49; -} - -.node-status--review b { - color: #8c662b; -} - -.node-status--generating b { - color: #42674b; -} - -.node-action { - width: 100%; - min-height: 34px; - padding: 7px 9px; - border-radius: 8px; - color: #edf4ee; - background: #34533d; - font-size: 8px; - font-weight: 750; -} - -.node-action:hover:not(:disabled) { - background: #263f2e; -} - -.node-action:disabled { - color: #969d97; - background: #e2e5e1; - cursor: not-allowed; -} - -.node-action--confirm { - color: #304b37; - background: #dce8de; -} - -.project-node-facts { - display: grid; - gap: 6px; - margin: 0; -} - -.project-node-facts div { - display: flex; - justify-content: space-between; - padding-bottom: 5px; - border-bottom: 1px solid #e6e8e5; - font-size: 8px; -} - -.project-node-facts dt { - color: #848a84; -} - -.project-node-facts dd { - margin: 0; - font-weight: 700; -} - -.node-source-list { - display: grid; - gap: 5px; -} - -.node-source-list button { - display: grid; - gap: 2px; - width: 100%; - padding: 8px 9px; - border: 1px solid #dce0db; - border-radius: 8px; - color: #303730; - background: #f0f2ef; - text-align: left; -} - -.node-source-list button:hover:not(:disabled), .node-source-list button.is-selected { - border-color: #6c8872; - background: #e3ebe4; -} - -.node-source-list button:disabled { - opacity: 0.48; - cursor: not-allowed; -} - -.node-source-list span { - font-size: 8px; - font-weight: 750; -} - -.node-source-list small { - color: #828982; - font-size: 6px; - letter-spacing: 0.06em; -} - -.master-choice-grid { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 5px; -} - -.master-choice-grid button { - display: grid; - gap: 3px; - padding: 3px; - border: 1px solid #d9ddd8; - border-radius: 7px; - background: #edf0ec; -} - -.master-choice-grid button.is-selected { - border-color: #41694c; - box-shadow: 0 0 0 2px rgba(65, 105, 76, 0.15); -} - -.master-choice-grid img { - width: 100%; - aspect-ratio: 1; - object-fit: contain; - image-rendering: pixelated; -} - -.master-choice-grid span { - overflow: hidden; - padding: 2px; - font-size: 6px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.confirmed-master, .keyframe-preview { - display: grid; - margin: 0; - place-items: center; - overflow: hidden; - border-radius: 8px; - background: #e9ece8; -} - -.confirmed-master img { - width: 158px; - height: 158px; - object-fit: contain; - image-rendering: pixelated; -} - -.keyframe-preview img { - width: 126px; - height: 126px; - object-fit: contain; - image-rendering: pixelated; -} - -.confirmed-master figcaption, .keyframe-preview figcaption { - width: 100%; - padding: 6px; - color: #6e766f; - background: #e0e5e0; - font: 700 6px monospace; - text-align: center; -} - -.node-generation { - position: relative; - display: grid; - height: 150px; - place-items: center; - overflow: hidden; - border-radius: 8px; - background: #e5e9e5; -} - -.node-generation img { - position: relative; - z-index: 2; - width: 132px; - height: 132px; - object-fit: contain; - image-rendering: pixelated; - animation: node-image-resolve 9s cubic-bezier(.3,.05,.2,1) both; -} - -.node-generation > small { - position: absolute; - z-index: 5; - right: 7px; - bottom: 6px; - color: #657168; - font-size: 6px; -} - -.node-generation__dots { - position: absolute; - z-index: 3; - inset: 13px; - display: grid; - grid-template-columns: repeat(9, 1fr); - grid-template-rows: repeat(9, 1fr); - place-items: center; - opacity: .82; - mask-image: radial-gradient(circle at center, #000 18%, rgba(0, 0, 0, .9) 58%, transparent 96%); -} - -.node-generation__dots i { - width: 3px; - height: 3px; - border-radius: 50%; - opacity: .12; - background: #456c51; - box-shadow: 0 0 9px rgba(69, 108, 81, .24); - animation: node-dot-ripple 2.65s cubic-bezier(.35,0,.2,1) infinite; -} - -.node-generation__dots .dot-ring-0 { - animation-delay: 0s; -} - -.node-generation__dots .dot-ring-1 { - animation-delay: .12s; -} - -.node-generation__dots .dot-ring-2 { - animation-delay: .24s; -} - -.node-generation__dots .dot-ring-3 { - animation-delay: .36s; -} - -.node-generation__dots .dot-ring-4 { - animation-delay: .48s; -} - -.node-frame-strip { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 4px; -} - -.node-frame-strip > span { - position: relative; - display: grid; - aspect-ratio: 1; - place-items: center; - overflow: hidden; - border-radius: 5px; - background: #e7eae6; -} - -.node-frame-strip img { - width: 100%; - height: 100%; - object-fit: contain; - image-rendering: pixelated; -} - -.node-frame-strip small { - position: absolute; - right: 3px; - bottom: 2px; - color: #667168; - font: 700 5px monospace; -} - -.node-frame-strip.is-revealing > span { - opacity: 1; - filter: none; -} - -.node-frame-strip > span.is-arrived { - animation: node-frame-reveal 420ms ease both; -} - -.node-frame-strip > span.is-pending { - border: 1px dashed rgba(69, 108, 81, .2); -} - -.node-frame-strip > span.is-pending > i { - width: 8px; - height: 8px; - border-radius: 50%; - background: rgba(69, 108, 81, .28); - animation: node-pending-pulse 1.1s ease-in-out infinite alternate; -} - -.master-arrival-grid { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 5px; -} - -.master-arrival-grid > span { - position: relative; - display: grid; - aspect-ratio: .78; - place-items: center; - overflow: hidden; - border: 1px solid rgba(69, 108, 81, .12); - border-radius: 6px; - background: #e7eae6; -} - -.master-arrival-grid img { - width: 100%; - height: 100%; - object-fit: contain; - image-rendering: pixelated; -} - -.master-arrival-grid > span.is-arrived { - animation: node-frame-reveal 420ms ease both; -} - -.master-arrival-grid > span.is-pending { - border-style: dashed; -} - -.master-arrival-grid > span.is-pending > i { - width: 8px; - height: 8px; - border-radius: 50%; - background: rgba(69, 108, 81, .28); - animation: node-pending-pulse 1.1s ease-in-out infinite alternate; -} - -.master-arrival-grid small { - position: absolute; - right: 4px; - bottom: 3px; - color: #667168; - font: 700 5px monospace; -} - -.publish-complete { - display: grid; - gap: 5px; - padding: 10px; - border-radius: 8px; - color: #33503b; - background: #e1ece3; -} - -.publish-complete b { - font-size: 11px; -} - -.publish-complete span, .publish-complete a { - font-size: 7px; -} - -.publish-complete a { - margin-top: 4px; - color: #335f40; - font-weight: 750; -} - -.publish-options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-top: 7px; -} - -.publish-options > button, .publish-options > a { - display: grid; - min-height: 78px; - align-content: center; - gap: 5px; - margin: 0; - padding: 11px; - border: 1px solid #bfd0c2; - border-radius: 9px; - color: #304c37; - background: rgba(249, 251, 248, .72); - text-align: left; -} - -.publish-options > button:hover, .publish-options > a:hover { - border-color: #6d8b74; - background: #f7faf7; - box-shadow: 0 7px 18px rgba(44, 69, 51, .1); -} - -.publish-options b { - font-size: 10px; -} - -.publish-options small { - color: #718077; - font-size: 7px; - line-height: 1.45; -} - -.workflow-save-form { - display: grid; - grid-template-columns: 1fr auto; - gap: 7px; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid #cbd9ce; -} - -.workflow-save-form label { - display: grid; - gap: 5px; -} - -.workflow-save-form label span { - color: #657169; - font-size: 7px; - font-weight: 750; -} - -.workflow-save-form input { - width: 100%; - height: 34px; - padding: 0 9px; - border: 1px solid #c7d3c9; - border-radius: 7px; - outline: 0; - background: rgba(250, 252, 249, .9); - font-size: 8px; -} - -.workflow-save-form input:focus { - border-color: #66836e; - box-shadow: 0 0 0 3px rgba(82, 119, 92, .1); -} - -.workflow-save-form button { - align-self: end; - height: 34px; - padding: 0 10px; - border-radius: 7px; - color: #fff; - background: #35583f; - font-size: 8px; - font-weight: 750; -} - -.workflow-save-form small { - grid-column: 1 / -1; - color: #55705d; - font-size: 7px; -} - -.node-canvas-hint, .node-zoom { - position: absolute; - z-index: 15; - bottom: 18px; - border: 1px solid rgba(36, 49, 39, 0.12); - border-radius: 10px; - background: rgba(249, 250, 247, 0.88); - box-shadow: 0 8px 24px rgba(31, 40, 33, 0.09); - backdrop-filter: blur(14px); -} - -.node-canvas-hint { - left: 18px; - display: flex; - align-items: center; - gap: 14px; - padding: 7px 8px 7px 12px; -} - -.node-canvas-hint__copy { - display: grid; - gap: 2px; -} - -.node-canvas-hint__copy b { - color: #29342c; - font-size: 7px; -} - -.node-canvas-hint__copy > span { - color: #777f78; - font-size: 6px; -} - -.node-canvas-hint > button { - display: inline-flex; - min-height: 31px; - align-items: center; - gap: 7px; - padding: 0 10px; - border: 1px solid rgba(61, 83, 67, .12); - border-radius: 8px; - color: #385040; - background: rgba(229, 237, 231, .78); - font-size: 7px; - font-weight: 750; - white-space: nowrap; -} - -.node-canvas-hint > button:hover { - border-color: rgba(61, 101, 72, .26); - background: rgba(215, 229, 219, .92); -} - -.node-canvas-hint > button i { - display: grid; - min-width: 16px; - height: 16px; - place-items: center; - border-radius: 999px; - color: #fff; - background: #496b53; - font-size: 7px; - font-style: normal; -} - -.node-zoom { - right: 18px; - display: flex; - height: 38px; - align-items: center; - padding: 3px; -} - -.node-zoom button { - display: grid; - width: 31px; - height: 31px; - place-items: center; - border-radius: 7px; - color: #344339; - background: transparent; - font-size: 15px; -} - -.node-zoom button:hover { - background: #e5eae5; -} - -.node-zoom output { - width: 44px; - color: #5c665e; - font: 700 7px monospace; - text-align: center; -} - -.project-setup { - position: relative; - display: grid; - width: 100%; - min-height: 100svh; - grid-template-columns: minmax(390px, 41vw) minmax(560px, 1fr); - margin: 0 auto; - overflow: hidden; - border: 0; - border-radius: 0; - background: rgba(246, 246, 242, .72); - box-shadow: none; - -webkit-backdrop-filter: blur(30px) saturate(108%); - backdrop-filter: blur(30px) saturate(108%); - animation: project-console-enter 720ms cubic-bezier(.22, 1, .36, 1) both; -} - -.production-canvas-workspace:has(> .project-setup) { - position: relative; - height: 100svh; - padding: 0; - overflow: auto; - background: radial-gradient(circle at 22% 34%, rgba(0, 0, 0, .06), transparent 31%), radial-gradient(circle at 78% 68%, rgba(255, 255, 255, .72), transparent 35%), #e8e8e4; -} - -.production-canvas-workspace:has(> .project-setup)::before { - position: fixed; - z-index: 0; - inset: 0; - content: ""; - opacity: .48; - background-image: radial-gradient(circle, rgba(20, 20, 20, .24) 1px, transparent 1.4px); - background-size: 22px 22px; - -webkit-mask-image: radial-gradient(ellipse at center, #000 18%, rgba(0, 0, 0, .72) 64%, transparent 96%); - mask-image: radial-gradient(ellipse at center, #000 18%, rgba(0, 0, 0, .72) 64%, transparent 96%); - animation: project-pixel-drift 18s linear infinite, project-pixel-breathe 5.5s ease-in-out infinite alternate; - pointer-events: none; -} - -.production-canvas-workspace:has(> .project-setup) > .project-setup { - z-index: 1; -} - -.project-setup__intro { - position: relative; - display: flex; - flex-direction: column; - padding: 104px clamp(42px, 5.2vw, 88px) 48px; - overflow: hidden; - color: #f4f4f1; - background: linear-gradient(145deg, #070807, #181918 74%, #0d0e0d); -} - -.project-setup__intro::before { - position: absolute; - inset: 0; - content: ""; - opacity: .28; - background-image: radial-gradient(circle, rgba(255, 255, 255, .54) 1px, transparent 1.25px); - background-size: 18px 18px; - -webkit-mask-image: linear-gradient(135deg, transparent 5%, #000 46%, transparent 94%); - mask-image: linear-gradient(135deg, transparent 5%, #000 46%, transparent 94%); - animation: project-pixel-drift 22s linear infinite reverse; - pointer-events: none; -} - -.project-setup__intro > * { - position: relative; - z-index: 1; -} - -.project-setup__edition { - display: grid; - grid-template-columns: 38px 1fr auto; - gap: 12px; - align-items: center; -} - -.project-setup__edition > i { - padding: 6px 8px; - border: 1px solid rgba(255, 255, 255, .2); - border-radius: 999px; - color: #d0d0cc; - background: rgba(255, 255, 255, .06); - font: 700 6px/1 monospace; - font-style: normal; - letter-spacing: .12em; -} - -.project-setup__mark { - position: relative; - display: block; - width: 38px; - height: 38px; - border: 1px solid rgba(255, 255, 255, .24); - border-radius: 11px; - background: rgba(255, 255, 255, .07); - box-shadow: inset 0 1px rgba(255, 255, 255, .09); -} - -.project-setup__mark::before, .project-setup__mark::after { - position: absolute; - top: 18px; - left: 9px; - width: 19px; - height: 1px; - content: ""; - background: rgba(255, 255, 255, .46); - transform: rotate(29deg); -} - -.project-setup__mark::after { - transform: rotate(-29deg); -} - -.project-setup__mark i { - position: absolute; - width: 6px; - height: 6px; - border: 1px solid #d7d7d2; - border-radius: 2px; - background: #181918; - box-shadow: 0 0 0 3px rgba(255, 255, 255, .07); - animation: project-node-signal 2.8s ease-in-out infinite; -} - -.project-setup__mark i:nth-child(1) { - top: 7px; - left: 7px; -} - -.project-setup__mark i:nth-child(2) { - top: 7px; - right: 7px; - animation-delay: .35s; -} - -.project-setup__mark i:nth-child(3) { - right: 7px; - bottom: 7px; - animation-delay: .7s; -} - -.project-setup__mark i:nth-child(4) { - bottom: 7px; - left: 7px; - animation-delay: 1.05s; -} - -.project-setup__identity { - display: grid; - gap: 3px; -} - -.project-setup__identity b { - font-size: 9px; - font-weight: 650; -} - -.project-setup__identity small { - color: #8f908c; - font-size: 5px; - letter-spacing: .2em; -} - -.project-setup__intro h1 { - max-width: 330px; - margin: 42px 0 14px; - font: 500 34px/1.16 Georgia, "Songti SC", serif; - letter-spacing: -.02em; -} - -.project-setup__intro > p { - max-width: 330px; - margin: 0; - color: #aaa; - font-size: 9px; - line-height: 1.85; -} - -.project-setup__route { - display: grid; - grid-template-columns: repeat(3, 1fr); - margin-top: 28px; - border-top: 1px solid rgba(255, 255, 255, .12); - border-bottom: 1px solid rgba(255, 255, 255, .12); -} - -.project-setup__route span { - position: relative; - display: flex; - min-height: 46px; - align-items: center; - gap: 8px; -} - -.project-setup__route span:not(:last-child)::after { - position: absolute; - top: 50%; - right: 12px; - width: 18px; - height: 1px; - content: ""; - background: rgba(255, 255, 255, .2); -} - -.project-setup__route i { - color: #8d8e8a; - font: 700 6px monospace; - font-style: normal; -} - -.project-setup__route b { - color: #d2d2ce; - font-size: 7px; - font-weight: 650; -} - -.project-setup__matrix { - position: absolute; - z-index: 0; - top: 82px; - right: 22px; - bottom: 150px; - left: 22px; - opacity: .34; - pointer-events: none; -} - -.project-setup__matrix::before { - position: absolute; - inset: 0; - content: ""; - background-image: radial-gradient(circle, rgba(255, 255, 255, .44) 1px, transparent 1.25px); - background-size: 10px 10px; - -webkit-mask-image: linear-gradient(110deg, transparent 8%, #000 38%, #000 68%, transparent 94%); - mask-image: linear-gradient(110deg, transparent 8%, #000 38%, #000 68%, transparent 94%); - animation: project-matrix-shift 12s linear infinite; -} - -.project-setup__matrix-node { - position: absolute; - width: 68px; - height: 40px; - border: 1px solid rgba(255, 255, 255, .46); - border-radius: 6px; - background-image: radial-gradient(circle, rgba(255, 255, 255, .62) 1px, transparent 1.2px); - background-size: 8px 8px; - box-shadow: inset 0 0 0 5px rgba(255, 255, 255, .025); - animation: project-matrix-node 4.8s ease-in-out infinite; -} - -.project-setup__matrix-node::before, .project-setup__matrix-node::after { - position: absolute; - top: 50%; - width: 5px; - height: 5px; - margin-top: -3px; - border: 1px solid rgba(255, 255, 255, .76); - border-radius: 50%; - content: ""; - background: #111; -} - -.project-setup__matrix-node::before { - left: -4px; -} - -.project-setup__matrix-node::after { - right: -4px; -} - -.project-setup__matrix-node--source { - top: 32%; - left: 3%; -} - -.project-setup__matrix-node--master { - top: 32%; - left: 39%; - animation-delay: .6s; -} - -.project-setup__matrix-node--walk { - top: 18%; - right: 3%; - animation-delay: 1.2s; -} - -.project-setup__matrix-node--idle { - top: 48%; - right: 3%; - animation-delay: 1.8s; -} - -.project-setup__matrix-path { - position: absolute; - height: 2px; - overflow: hidden; - background-image: radial-gradient(circle, rgba(255, 255, 255, .82) 1px, transparent 1.5px); - background-size: 8px 2px; - transform-origin: left center; -} - -.project-setup__matrix-path--trunk { - top: calc(32% + 20px); - left: calc(3% + 68px); - width: 25%; -} - -.project-setup__matrix-path--walk { - top: calc(32% + 20px); - left: calc(39% + 68px); - width: 25%; - transform: rotate(-18deg); -} - -.project-setup__matrix-path--idle { - top: calc(32% + 20px); - left: calc(39% + 68px); - width: 25%; - transform: rotate(18deg); -} - -.project-setup__matrix-signal { - position: absolute; - width: 4px; - height: 4px; - border-radius: 1px; - background: #fff; - box-shadow: 0 0 8px rgba(255, 255, 255, .8); -} - -.project-setup__matrix-signal--1 { - top: calc(32% + 19px); - left: calc(3% + 70px); - animation: project-signal-trunk 3.2s linear infinite; -} - -.project-setup__matrix-signal--2 { - top: calc(32% + 19px); - left: calc(39% + 70px); - animation: project-signal-walk 3.2s .8s linear infinite; -} - -.project-setup__matrix-signal--3 { - top: calc(32% + 19px); - left: calc(39% + 70px); - animation: project-signal-idle 3.2s 1.6s linear infinite; -} - -.workflow-template-picker { - display: grid; - gap: 8px; - margin-top: auto; - padding-top: 28px; -} - -.workflow-template-picker > header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(255, 255, 255, .14); -} - -.workflow-template-picker > header b { - font-size: 9px; -} - -.workflow-template-picker > header small { - color: #969793; - font-size: 7px; -} - -.workflow-template-picker > button { - display: grid; - min-height: 54px; - grid-template-columns: 26px 1fr auto; - gap: 10px; - align-items: center; - padding: 8px 11px; - border: 1px solid rgba(255, 255, 255, .11); - border-radius: 11px; - color: #eeeeea; - background: rgba(255, 255, 255, .045); - text-align: left; - transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; -} - -.workflow-template-picker > button:hover { - border-color: rgba(255, 255, 255, .28); - background: rgba(255, 255, 255, .08); - transform: translateX(2px); -} - -.workflow-template-picker > button.is-selected { - border-color: rgba(255, 255, 255, .4); - background: linear-gradient(90deg, rgba(255, 255, 255, .14), rgba(255, 255, 255, .06)); -} - -.workflow-template-picker > button.workflow-template-picker__empty { - border-style: dashed; -} - -.workflow-template-picker > button.workflow-template-picker__empty.is-selected { - border-style: solid; - box-shadow: inset 2px 0 0 #f4f4ef; -} - -.workflow-template-picker button em { - display: grid; - width: 24px; - height: 24px; - place-items: center; - border-radius: 7px; - color: #aaa; - background: rgba(255, 255, 255, .07); - font: 700 6px monospace; - font-style: normal; -} - -.workflow-template-picker button span { - display: grid; - gap: 4px; -} - -.workflow-template-picker button b { - font-size: 9px; -} - -.workflow-template-picker button small { - color: #9c9d99; - font-size: 6px; -} - -.workflow-template-picker button > i { - color: #c2c2bd; - font-size: 7px; - font-style: normal; -} - -.project-setup__form { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px 16px; - align-content: center; - padding: 108px clamp(48px, 7vw, 124px) 54px; - background: linear-gradient(150deg, rgba(255, 255, 252, .88), rgba(239, 239, 235, .7)); -} - -.project-setup__form-head { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 24px; - margin-bottom: 10px; - padding-bottom: 20px; - border-bottom: 1px solid rgba(49, 65, 53, .1); -} - -.project-setup__form-head > div { - display: grid; - gap: 7px; -} - -.project-setup__form-head .overline { - color: #7e7f7b; - font-size: 6px; - letter-spacing: .18em; -} - -.project-setup__form-head h2 { - margin: 0; - color: #1d1e1d; - font: 500 24px/1.15 Georgia, "Songti SC", serif; -} - -.project-setup__form-head > span { - padding: 7px 9px; - border: 1px solid rgba(20, 20, 20, .12); - border-radius: 999px; - color: #555653; - background: rgba(228, 228, 224, .68); - font: 700 6px monospace; - letter-spacing: .1em; -} - -.project-setup__form label { - display: grid; - gap: 8px; -} - -.project-setup__form label > span:first-child { - color: #5d695f; - font-size: 8px; - font-weight: 750; - letter-spacing: .02em; -} - -.project-setup__wide { - grid-column: 1 / -1; -} - -.project-setup__form input:not([type="file"]), .project-setup__form select, .project-setup__form textarea { - width: 100%; - border: 1px solid rgba(20, 20, 20, .14); - border-radius: 11px; - outline: none; - color: #242524; - background: rgba(231, 231, 227, .64); - box-shadow: inset 0 1px rgba(255, 255, 255, .68); - font: inherit; - font-size: 9px; - transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; -} - -.project-setup__form input:not([type="file"]), .project-setup__form select { - height: 46px; - padding: 0 13px; -} - -.project-setup__form textarea { - min-height: 88px; - padding: 13px; - resize: none; - line-height: 1.65; -} - -.project-setup__form input:focus, .project-setup__form select:focus, .project-setup__form textarea:focus { - border-color: rgba(20, 20, 20, .58); - background: rgba(249, 249, 246, .9); - box-shadow: 0 0 0 4px rgba(20, 20, 20, .07), inset 0 1px rgba(255, 255, 255, .76); -} - -.project-reference { - display: flex; - min-height: 42px; - align-items: center; - justify-content: space-between; - padding: 7px 10px; - border: 1px dashed rgba(82, 101, 87, .24); - border-radius: 10px; - background: rgba(248, 250, 247, .42); -} - -.project-reference input { - width: 180px; - font-size: 7px; -} - -.project-reference small { - color: #8b918b; - font-size: 6px; -} - -.project-setup__form footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - margin-top: 2px; - padding-top: 20px; - border-top: 1px solid rgba(49, 65, 53, .09); -} - -.project-setup__form footer > span { - display: flex; - align-items: center; - gap: 8px; -} - -.project-setup__form footer > span > i { - width: 6px; - height: 6px; - border-radius: 2px; - background: #252625; - box-shadow: 0 0 0 4px rgba(20, 20, 20, .08); -} - -.project-setup__form footer small { - color: #748078; - font-size: 7px; -} - -.project-setup__form footer button { - display: inline-flex; - min-height: 48px; - align-items: center; - gap: 18px; - padding: 0 18px 0 21px; - border-radius: 12px; - color: #f6f6f3; - background: #171817; - box-shadow: 0 16px 34px rgba(10, 10, 10, .18); - font-size: 10px; -} - -.project-setup__form footer button:hover { - background: #050505; - transform: translateY(-1px); -} - -.project-setup__form footer button i { - display: grid; - width: 24px; - height: 24px; - place-items: center; - border-radius: 7px; - background: rgba(255, 255, 255, .09); - font-size: 10px; - font-style: normal; -} - -/* The setup screen follows the product's editorial black/paper language. The - dot field is contained inside one legible node-workspace preview. */ -.project-setup { - grid-template-columns: minmax(430px, 35vw) minmax(600px, 1fr); - background: #f4f3ef; -} - -.production-canvas-workspace:has(> .project-setup) { - background: #f4f3ef; -} - -.production-canvas-workspace:has(> .project-setup)::before { - display: none; -} - -.project-setup__intro { - padding: 106px clamp(48px, 4.3vw, 76px) 42px; - background: linear-gradient(152deg, #070807 0%, #121312 58%, #0a0b0a 100%); -} - -.project-setup__intro::before { - opacity: .1; - background-size: 24px 24px; - -webkit-mask-image: radial-gradient(circle at 72% 44%, #000 0, rgba(0, 0, 0, .64) 28%, transparent 64%); - mask-image: radial-gradient(circle at 72% 44%, #000 0, rgba(0, 0, 0, .64) 28%, transparent 64%); -} - -.project-setup__identity b { - font-size: 11px; -} - -.project-setup__identity small { - font-size: 6px; -} - -.project-setup__edition > i { - font-size: 7px; -} - -.project-setup__intro h1 { - max-width: 390px; - margin: 34px 0 13px; - font-size: 38px; - line-height: 1.14; -} - -.project-setup__intro > p { - max-width: 390px; - color: #aaa9a5; - font-size: 11px; - line-height: 1.75; -} - -.project-setup__route { - margin-top: 24px; -} - -.project-setup__route span { - min-height: 48px; -} - -.project-setup__route i { - font-size: 7px; -} - -.project-setup__route b { - font-size: 9px; -} - -.project-setup__matrix { - top: 390px; - right: clamp(42px, 4vw, 68px); - bottom: auto; - left: clamp(42px, 4vw, 68px); - height: 160px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, .11); - border-radius: 16px; - opacity: 1; - background: rgba(255, 255, 255, .025); - box-shadow: inset 0 1px rgba(255, 255, 255, .035), 0 18px 50px rgba(0, 0, 0, .18); -} - -.project-setup__matrix::before { - opacity: .24; - background-size: 12px 12px; - -webkit-mask-image: linear-gradient(90deg, #000, rgba(0, 0, 0, .58), #000); - mask-image: linear-gradient(90deg, #000, rgba(0, 0, 0, .58), #000); -} - -.project-setup__matrix::after { - position: absolute; - top: 11px; - left: 14px; - content: "NODE WORKSPACE / LIVE"; - color: rgba(255, 255, 255, .34); - font: 700 6px monospace; - letter-spacing: .15em; -} - -.project-setup__matrix-node { - z-index: 2; - display: grid; - width: 88px; - height: 45px; - align-content: center; - gap: 4px; - padding: 7px 10px; - border-color: rgba(255, 255, 255, .26); - background: rgba(13, 14, 13, .88); - box-shadow: inset 0 1px rgba(255, 255, 255, .045), 0 8px 20px rgba(0, 0, 0, .2); -} - -.project-setup__matrix-node small { - color: rgba(255, 255, 255, .38); - font: 700 5px monospace; - letter-spacing: .08em; -} - -.project-setup__matrix-node b { - color: rgba(255, 255, 255, .78); - font-size: 7px; - font-weight: 650; -} - -.project-setup__matrix-node--source { - top: 66px; - left: 16px; -} - -.project-setup__matrix-node--master { - top: 66px; - left: 38%; -} - -.project-setup__matrix-node--walk { - top: 36px; - right: 16px; -} - -.project-setup__matrix-node--idle { - top: 96px; - right: 16px; -} - -.project-setup__matrix-path--trunk { - top: 88px; - left: 104px; - width: calc(38% - 104px); -} - -.project-setup__matrix-path--walk { - top: 88px; - left: calc(38% + 88px); - width: 22%; - transform: rotate(-14deg); -} - -.project-setup__matrix-path--idle { - top: 88px; - left: calc(38% + 88px); - width: 22%; - transform: rotate(14deg); -} - -.project-setup__matrix-signal--1 { - top: 87px; - left: 106px; - animation: project-signal-trunk 3.8s linear infinite; -} - -.project-setup__matrix-signal--2 { - top: 87px; - left: calc(38% + 90px); - animation: project-signal-walk 3.8s .9s linear infinite; -} - -.project-setup__matrix-signal--3 { - top: 87px; - left: calc(38% + 90px); - animation: project-signal-idle 3.8s 1.8s linear infinite; -} - -.workflow-template-picker { - gap: 9px; - padding-top: 24px; -} - -.workflow-template-picker > header b { - font-size: 11px; -} - -.workflow-template-picker > header small { - font-size: 8px; -} - -.workflow-template-picker > button { - min-height: 57px; - padding: 9px 12px; -} - -.workflow-template-picker button b { - font-size: 10px; -} - -.workflow-template-picker button small { - font-size: 7px; -} - -.workflow-template-picker button > i { - font-size: 8px; -} - -.project-setup__form { - grid-template-columns: repeat(2, minmax(0, 310px)); - justify-content: center; - gap: 18px 20px; - padding: 112px 52px 54px; - background: #f6f5f1; -} - -.project-setup__form-head { - margin-bottom: 12px; - padding-bottom: 22px; - border-bottom-color: rgba(20, 20, 20, .11); -} - -.project-setup__form-head .overline { - font-size: 7px; -} - -.project-setup__form-head h2 { - font-size: 30px; -} - -.project-setup__form-head > span { - font-size: 7px; -} - -.project-setup__form label > span:first-child { - color: #555653; - font-size: 10px; -} - -.project-setup__form input:not([type="file"]), .project-setup__form select, .project-setup__form textarea { - border-color: rgba(20, 20, 20, .16); - border-radius: 10px; - background: rgba(235, 235, 231, .76); - font-size: 11px; -} - -.project-setup__form input:not([type="file"]), .project-setup__form select { - height: 48px; -} - -.project-setup__form textarea { - min-height: 94px; -} - -.project-reference { - border-color: rgba(20, 20, 20, .2); - background: rgba(255, 255, 252, .52); -} - -.project-reference input { - font-size: 8px; -} - -.project-reference small { - color: #7a7b77; - font-size: 7px; -} - -.project-setup__form footer { - padding-top: 22px; - border-top-color: rgba(20, 20, 20, .1); -} - -.project-setup__form footer small { - color: #656662; - font-size: 8px; -} - -.project-setup__form footer button { - min-height: 50px; - padding-left: 22px; - font-size: 11px; -} - -.workflow-app:has(.project-setup) .studio-bar__left { - border-color: rgba(255, 255, 255, .16); - color: #f4f4f1; - background: linear-gradient(180deg, rgba(28, 29, 28, .68), rgba(10, 11, 10, .52)); - box-shadow: 0 14px 38px rgba(0, 0, 0, .22), inset 0 1px rgba(255, 255, 255, .09); - -webkit-backdrop-filter: blur(26px) saturate(105%); - backdrop-filter: blur(26px) saturate(105%); -} - -.workflow-app:has(.project-setup) .studio-bar__left .product-brand__mark { - background: #f5f5f1; -} - -.workflow-app:has(.project-setup) .studio-bar__brand { - border-right-color: rgba(255, 255, 255, .14); - color: #f5f5f1; -} - -.workflow-app:has(.project-setup) .studio-bar__project small { - color: rgba(255, 255, 255, .48); -} - -.workflow-app:has(.project-setup) .studio-bar__right { - border-color: rgba(255, 255, 255, .76); - background: linear-gradient(180deg, rgba(255, 255, 252, .68), rgba(238, 238, 234, .44)); - box-shadow: 0 14px 38px rgba(20, 20, 20, .09), inset 0 1px rgba(255, 255, 255, .76); -} - -.workflow-app:has(.project-setup) .studio-bar__nav a.is-active { - color: #171817; - background: rgba(218, 218, 214, .86); -} - -.workflow-app:has(.project-setup) .studio-bar__actions button i { - color: #f5f5f1; - background: #242524; -} - -/* Minimal project entry: white canvas, one workbench glyph, no promotional - copy or navigation chrome. */ -body:has(.project-setup) { - overflow: hidden; -} - -.workflow-app:has(.project-setup) { - position: fixed; - inset: 0; - width: 100vw; - height: 100svh; - overflow: hidden; -} - -.workflow-app:has(.project-setup) .production-canvas-workspace { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - min-height: 0; - overflow: hidden; -} - -.workflow-app:has(.project-setup) .studio-bar { - display: flex; -} - -.workflow-app:has(.project-setup) .studio-bar__left, .workflow-app:has(.project-setup) .studio-bar__right { - border-color: rgba(23, 24, 23, .14); - color: #171817; - background: var(--workspace-white); - box-shadow: none; - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - -.workflow-app:has(.project-setup) .studio-bar__left .product-brand__mark { - background: #171817; -} - -.workflow-app:has(.project-setup) .studio-bar__brand { - border-right-color: rgba(20, 20, 20, .12); - color: #171817; -} - -.workflow-app:has(.project-setup) .studio-bar__project small { - color: #747571; -} - -.project-setup { - grid-template-columns: minmax(360px, 34vw) minmax(620px, 1fr); - height: 100%; - min-height: 0; - color: #171817; - background: var(--workspace-white); -} - -.production-canvas-workspace:has(> .project-setup) { - background: var(--workspace-white); -} - -.project-setup__intro { - display: grid; - padding: 0; - place-items: center; - color: #171817; - border-right: 0; - background: var(--workspace-white); -} - -.project-setup__intro::before { - display: none; -} - -.project-setup__pixel-icon { - position: relative; - z-index: 1; - display: grid; - width: 248px; - height: 248px; - grid-template-columns: repeat(17, 5px); - grid-template-rows: repeat(17, 5px); - place-content: center; - gap: 8px; - border-radius: 40px; - background: radial-gradient(circle, rgba(17, 18, 17, .055), transparent 68%); -} - -.project-setup__pixel-icon i { - width: 5px; - height: 5px; - border-radius: 1px; - opacity: .62; - background: #bfc6bf; - box-shadow: 0 0 0 1px rgba(255, 255, 255, .18); - animation: project-pixel-field 4.6s cubic-bezier(.4, 0, .2, 1) infinite; -} - -.project-setup__pixel-icon .pixel-ring-0 { - animation-delay: 0s; -} - -.project-setup__pixel-icon .pixel-ring-1 { - animation-delay: -.14s; -} - -.project-setup__pixel-icon .pixel-ring-2 { - animation-delay: -.28s; -} - -.project-setup__pixel-icon .pixel-ring-3 { - animation-delay: -.42s; -} - -.project-setup__pixel-icon .pixel-ring-4 { - animation-delay: -.56s; -} - -.project-setup__pixel-icon .pixel-ring-5 { - animation-delay: -.7s; -} - -.project-setup__pixel-icon .pixel-ring-6 { - animation-delay: -.84s; -} - -.project-setup__pixel-icon .pixel-ring-7 { - animation-delay: -.98s; -} - -.project-setup__pixel-icon .pixel-ring-8 { - animation-delay: -1.12s; -} - -.project-setup__pixel-icon i.is-active { - opacity: .9; - background: #202120; - box-shadow: none; - animation: project-pixel-frame 3.4s ease-in-out infinite; -} - -.project-setup__pixel-icon i.is-active.pixel-phase-1 { - animation-delay: -.18s; -} - -.project-setup__pixel-icon i.is-active.pixel-phase-2 { - animation-delay: -.36s; -} - -.project-setup__pixel-icon i.is-active.pixel-phase-3 { - animation-delay: -.54s; -} - -.project-setup__pixel-icon i.is-handle { - opacity: 1; - background: #050505; - box-shadow: 0 0 0 4px rgba(17, 18, 17, .09); - animation: project-pixel-handle 3.8s ease-in-out infinite; -} - -.project-setup__pixel-icon i.is-handle:nth-child(37) { - animation-delay: 0s; -} - -.project-setup__pixel-icon i.is-handle:nth-child(49) { - animation-delay: .32s; -} - -.project-setup__pixel-icon i.is-handle:nth-child(241) { - animation-delay: .96s; -} - -.project-setup__pixel-icon i.is-handle:nth-child(253) { - animation-delay: .64s; -} - -.project-setup__pixel-icon i.is-cursor { - opacity: 1; - background: #090a09; - animation: project-pixel-cursor 2.8s cubic-bezier(.22, 1, .36, 1) infinite; -} - -.project-setup__form { - grid-template-columns: repeat(2, minmax(0, 310px)); - align-content: center; - justify-content: center; - padding: 64px; - background: var(--workspace-white); -} - -.project-setup__form-head { - margin-bottom: 6px; - padding-bottom: 18px; -} - -.project-setup__form-head h2 { - font-size: 28px; -} - -.project-setup__form input:not([type="file"]), .project-setup__form select, .project-setup__form textarea { - background: #f5f5f2; -} - -.project-setup__form footer button { - border-radius: 10px; - box-shadow: 0 12px 28px rgba(10, 10, 10, .14); -} - -@keyframes project-pixel-field { - 0%, 58%, 100% { - opacity: .5; - transform: scale(.84); - } - - 16% { - opacity: .96; - transform: scale(1.08); - } - - 32% { - opacity: .68; - transform: scale(.94); - } - -} - -@keyframes project-pixel-frame { - 0%, 100% { - opacity: .72; - transform: scale(.86); - } - - 38% { - opacity: 1; - transform: scale(1.08); - } - - 62% { - opacity: .88; - transform: scale(.96); - } - -} - -@keyframes project-pixel-handle { - 0%, 24%, 100% { - transform: scale(.82); - box-shadow: 0 0 0 4px rgba(17, 18, 17, .07); - } - - 42%, 58% { - transform: scale(1.18); - box-shadow: 0 0 0 7px rgba(17, 18, 17, .1); - } - -} - -@keyframes project-pixel-cursor { - 0%, 14%, 100% { - opacity: .28; - transform: translate(-5px, -5px) scale(.86); - } - - 38% { - opacity: 1; - transform: translate(0, 0) scale(1); - } - - 68% { - opacity: .92; - transform: translate(4px, 4px) scale(1.06); - } - - 82% { - opacity: .46; - transform: translate(5px, 5px) scale(.9); - } - -} - -@keyframes project-console-enter { - from { - opacity: 0; - filter: blur(13px); - transform: translateY(24px) scale(.975); - } - -} - -@keyframes project-pixel-drift { - to { - background-position: 44px 22px; - } - -} - -@keyframes project-pixel-breathe { - from { - opacity: .36; - } - - to { - opacity: .66; - } - -} - -@keyframes project-node-signal { - 0%, 100% { - opacity: .52; - transform: scale(.82); - } - - 45% { - opacity: 1; - transform: scale(1.08); - box-shadow: 0 0 0 5px rgba(255, 255, 255, .12); - } - -} - -@keyframes project-matrix-shift { - to { - background-position: 20px 10px; - } - -} - -@keyframes project-matrix-node { - 0%, 100% { - opacity: .46; - } - - 42% { - opacity: .94; - box-shadow: inset 0 0 0 5px rgba(255, 255, 255, .04), 0 0 18px rgba(255, 255, 255, .08); - } - -} - -@keyframes project-signal-trunk { - 0% { - opacity: 0; - transform: translateX(0); - } - - 12%, 74% { - opacity: 1; - } - - 100% { - opacity: 0; - transform: translateX(96px); - } - -} - -@keyframes project-signal-walk { - 0% { - opacity: 0; - transform: translate(0, 0); - } - - 12%, 74% { - opacity: 1; - } - - 100% { - opacity: 0; - transform: translate(126px, -31px); - } - -} - -@keyframes project-signal-idle { - 0% { - opacity: 0; - transform: translate(0, 0); - } - - 12%, 74% { - opacity: 1; - } - - 100% { - opacity: 0; - transform: translate(126px, 31px); - } - -} - -@media (max-width: 1040px) { - .production-canvas-workspace:has(> .project-setup) { - padding: 0; - } - - .project-setup { - width: 100%; - grid-template-columns: 1fr; - } - - .project-setup__intro { - min-height: 590px; - padding: 96px 42px 42px; - } - - .project-setup__intro h1 { - margin-top: 32px; - } - - .project-setup__matrix { - display: none; - } - - .workflow-template-picker { - margin-top: 22px; - padding-top: 0; - } - - .project-setup__form { - min-height: 680px; - padding: 72px 42px 42px; - } - -} - -@media (min-width: 1041px) and (max-height: 820px) { - .project-setup__matrix { - top: 396px; - height: 96px; - } - - .project-setup__matrix::after { - top: 8px; - } - - .project-setup__matrix-node { - width: 76px; - height: 32px; - gap: 2px; - padding: 5px 8px; - } - - .project-setup__matrix-node small { - font-size: 4px; - } - - .project-setup__matrix-node b { - font-size: 6px; - } - - .project-setup__matrix-node--source { - top: 48px; - } - - .project-setup__matrix-node--master { - top: 48px; - } - - .project-setup__matrix-node--walk { - top: 30px; - } - - .project-setup__matrix-node--idle { - top: 64px; - } - - .project-setup__matrix-path--trunk { - top: 64px; - left: 92px; - width: calc(38% - 92px); - } - - .project-setup__matrix-path--walk, .project-setup__matrix-path--idle { - top: 64px; - left: calc(38% + 76px); - } - - .project-setup__matrix-signal--1 { - top: 63px; - left: 94px; - } - - .project-setup__matrix-signal--2, .project-setup__matrix-signal--3 { - top: 63px; - left: calc(38% + 78px); - } - -} - -@keyframes node-dot-ripple { - 0%, 9%, 100% { - opacity: .1; - transform: scale(.68); - } - - 22% { - opacity: .98; - transform: scale(1.42); - } - - 38% { - opacity: .28; - transform: scale(.88); - } - - 58% { - opacity: .1; - transform: scale(.72); - } - -} - -@keyframes node-image-resolve { - 0% { - opacity: .1; - filter: blur(19px) saturate(.55); - transform: scale(.88); - } - - 48% { - opacity: .55; - filter: blur(9px) saturate(.72); - } - - 100% { - opacity: 1; - filter: blur(0) saturate(1); - transform: scale(1); - } - -} - -@keyframes node-frame-reveal { - from { - opacity: 0; - filter: blur(11px); - transform: translateY(5px) scale(.9); - } - - to { - opacity: 1; - filter: blur(0); - transform: none; - } - -} - -@keyframes node-pending-pulse { - from { - opacity: .28; - transform: scale(.72); - } - - to { - opacity: .9; - transform: scale(1); - } - -} - -@media (max-width: 760px) { - .node-graph-toolbar { - top: 10px; - width: calc(100% - 20px); - } - - .node-graph-toolbar small { - display: none; - } - - .node-graph-toolbar button { - padding: 0 7px; - } - - .production-canvas-workspace:has(> .project-setup) { - padding: 0; - } - - .project-setup { - width: 100%; - grid-template-columns: 1fr; - margin: 0 auto; - border-radius: 0; - } - - .project-setup__intro { - padding: 30px; - } - - .project-setup__form { - grid-template-columns: 1fr; - padding: 28px 24px; - } - - .project-setup__form > * { - grid-column: 1 / -1; - } - - .project-setup__form-head, .project-setup__form footer { - align-items: flex-start; - flex-direction: column; - } - - .project-setup__form footer button { - width: 100%; - justify-content: space-between; - } - -} - -@media (prefers-reduced-motion: reduce) { - .node-generation img, .node-generation__dots i, .node-frame-strip.is-revealing > span { - animation: none !important; - opacity: 1; - filter: none; - } - - .project-setup, .production-canvas-workspace:has(> .project-setup)::before, .project-setup__intro::before, .project-setup__mark i, .project-setup__matrix *, .project-setup__matrix::before { - animation: none !important; - } - -} - -/* Product home: cinematic brand stage. The vector mark stays smooth; only the - canvas reinterpretation is rendered as a moving field of dots. */ -.workflow-app[data-route-id='home'] .product-header { - border-bottom-color: rgba(255, 255, 255, .13); - color: #fff; - background: linear-gradient(180deg, rgba(12, 14, 12, .86), rgba(5, 7, 5, .78)); - box-shadow: 0 12px 34px rgba(0, 0, 0, .22), inset 0 -1px 0 rgba(255, 255, 255, .05); - -webkit-backdrop-filter: blur(28px) saturate(125%) brightness(.24); - backdrop-filter: blur(28px) saturate(125%) brightness(.24); -} - -.workflow-app[data-route-id='home'] .product-brand__mark { - background: #fff; -} - -.workflow-app[data-route-id='home'] .product-brand__tag { - border-left-color: rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.55); -} - -.workflow-app[data-route-id='home'] .product-nav a { - color: rgba(255, 255, 255, 0.62); -} - -.workflow-app[data-route-id='home'] .product-nav a:hover, .workflow-app[data-route-id='home'] .product-nav a:focus-visible, .workflow-app[data-route-id='home'] .product-nav a.is-active { - color: #fff; - background: rgba(255, 255, 255, 0.1); -} - -.workflow-app[data-route-id='home'] .service-pill { - border-color: rgba(255, 255, 255, 0.13); - color: rgba(255, 255, 255, 0.6); - background: rgba(255, 255, 255, 0.06); -} - -.workflow-app[data-route-id='home'] .account-avatar { - border-color: rgba(255, 255, 255, 0.18); - color: #fff; - background: rgba(255, 255, 255, 0.08); -} - -.product-home .product-hero { - position: relative; - isolation: isolate; - display: grid; - grid-template-columns: minmax(390px, 0.86fr) minmax(520px, 1.14fr); - gap: 18px; - width: 100%; - min-height: calc(100svh - 76px); - margin: 0; - padding: 0 clamp(28px, 5vw, 88px); - overflow: hidden; - color: #fff; - background: #050505; -} - -.product-home .product-hero::after { - position: absolute; - z-index: 1; - inset: auto 0 0; - height: 32%; - content: ''; - background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.78)); - pointer-events: none; -} - -/* The first screen dissolves into the editorial surface instead of ending on - a hard black/paper seam. The veil overlaps the hero so backdrop blur can - soften the last row of pixels before the warm paper colour takes over. */ -.hero-to-paper { - position: relative; - z-index: 4; - height: clamp(520px, 72svh, 820px); - margin-top: -1px; - overflow: hidden; - background: linear-gradient( 180deg, #050505 0%, #090b09 20%, #252b26 43%, #858c83 65%, #d9dbd5 84%, var(--paper) 100% ); - pointer-events: none; -} - -.hero-to-paper__veil { - position: absolute; - inset: 18% -5% 3%; - background: linear-gradient(180deg, rgba(223, 227, 223, 0), rgba(223, 227, 223, 0.12) 40%, rgba(223, 227, 223, 0.92)); - backdrop-filter: blur(38px) saturate(0.76); - -webkit-backdrop-filter: blur(38px) saturate(0.76); - mask-image: linear-gradient(180deg, transparent, #000 20%, #000 92%, transparent); - -webkit-mask-image: linear-gradient(180deg, transparent, #000 20%, #000 92%, transparent); -} - -.hero-to-paper__mist { - position: absolute; - bottom: -24%; - width: 62vw; - height: 66%; - border-radius: 50%; - background: rgba(223, 227, 223, 0.72); - filter: blur(76px); - opacity: 0.7; -} - -.hero-to-paper__mist--left { - left: -18vw; - transform: rotate(7deg); -} - -.hero-to-paper__mist--right { - right: -16vw; - bottom: -18%; - opacity: 0.58; - transform: rotate(-8deg); -} - -.hero-to-paper + .continue-project { - position: relative; - z-index: 5; - margin-top: -74px; -} - -.brand-wave, .hero-noise { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.brand-wave { - z-index: 0; - opacity: 0.96; -} - -.hero-noise { - z-index: 0; - opacity: 0.15; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.18'/%3E%3C/svg%3E"); - mix-blend-mode: screen; - pointer-events: none; -} - -.product-home .hero-copy { - z-index: 3; - align-self: end; - max-width: 660px; - padding-bottom: clamp(70px, 10vh, 120px); -} - -.product-home .hero-copy .overline { - color: rgba(255, 255, 255, 0.48); -} - -.product-home .hero-copy h1 { - margin: 15px 0 18px; - color: #fff; - font-family: Inter, "PingFang SC", sans-serif; - font-size: clamp(48px, 5vw, 76px); - font-weight: 720; - letter-spacing: -0.055em; - line-height: 1.05; -} - -.product-home .hero-copy > p { - max-width: 610px; - color: rgba(255, 255, 255, 0.58); - font-size: 15px; - line-height: 1.8; -} - -.product-home .hero-actions { - margin-top: 28px; -} - -.product-home .hero-actions .button--primary { - border-color: #fff; - color: #111; - background: var(--workspace-white); - box-shadow: 0 12px 34px rgba(255, 255, 255, 0.11); -} - -.product-home .hero-actions .button--ghost { - border-color: rgba(255, 255, 255, 0.28); - color: #fff; - background: rgba(255, 255, 255, 0.07); - backdrop-filter: blur(12px); -} - -.character-crowd { - position: relative; - z-index: 2; - min-height: calc(100svh - 76px); - pointer-events: none; -} - -.crowd-character { - position: absolute; - opacity: 0; - image-rendering: pixelated; - filter: drop-shadow(0 18px 16px rgba(0, 0, 0, 0.72)); - transition: opacity 360ms ease, transform 540ms cubic-bezier(0.22, 1, 0.36, 1); - transform: translate3d(0, 12px, 0); -} - -.crowd-character.is-playing { - opacity: 1; - transform: translate3d(0, 0, 0); -} - -.cast-reaper { - z-index: 4; - top: 4%; - left: 4%; - width: clamp(160px, 14vw, 220px); -} - -.cast-schoolgirl { - z-index: 3; - top: 6%; - left: 36%; - width: clamp(120px, 11vw, 175px); -} - -.cast-knight { - z-index: 2; - top: 3%; - right: 4%; - width: clamp(155px, 14vw, 220px); -} - -.cast-samurai { - z-index: 3; - bottom: 28%; - left: 8%; - width: clamp(130px, 12vw, 190px); -} - -.cast-boy { - z-index: 5; - right: 28%; - bottom: 6%; - width: clamp(180px, 16vw, 260px); - filter: drop-shadow(0 22px 22px rgba(0, 0, 0, 0.78)); -} - -.cast-samurai-walk { - z-index: 1; - bottom: 10%; - right: 3%; - width: clamp(120px, 11vw, 175px); -} - -.character-crowd__label { - position: absolute; - right: 0; - bottom: 24px; - color: rgba(255, 255, 255, 0.33); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 9px; - letter-spacing: 0.15em; -} - -.hero-index { - position: absolute; - z-index: 3; - bottom: 28px; - left: clamp(28px, 5vw, 88px); - display: flex; - gap: 10px; - align-items: center; - color: rgba(255, 255, 255, 0.34); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 9px; - letter-spacing: 0.11em; -} - -.hero-index i { - width: 42px; - height: 1px; - background: rgba(255, 255, 255, 0.28); -} - -.continue-project { - margin-top: 42px; -} - -@media (max-width: 900px) { - .workflow-app[data-route-id='home'] .product-nav { - border-color: rgba(255, 255, 255, 0.14); - background: rgba(10, 10, 10, 0.9); - } - - .product-home .product-hero { - grid-template-columns: 1fr; - min-height: 920px; - padding-top: 40px; - } - - .product-home .hero-copy { - align-self: start; - max-width: 620px; - padding: 68px 0 0; - } - - .character-crowd { - position: absolute; - right: 12px; - bottom: 0; - width: 65%; - min-height: 620px; - } - - .brand-wave { - opacity: 0.7; - } - -} - -@media (max-width: 620px) { - .product-home .product-hero { - min-height: 860px; - padding-right: 18px; - padding-left: 18px; - } - - .product-home .hero-copy h1 { - font-size: 44px; - } - - .character-crowd { - right: -18%; - width: 105%; - min-height: 540px; - opacity: 0.78; - } - - .crowd-character { - width: 112px; - } - - .cast-reaper { - top: 14%; - left: 2%; - width: 145px; - } - - .cast-schoolgirl { - top: 16%; - left: 30%; - width: 108px; - } - - .cast-knight { - top: 12%; - right: 2%; - width: 140px; - } - - .cast-samurai { - bottom: 26%; - left: 6%; - width: 115px; - } - - .cast-boy { - right: 22%; - bottom: 5%; - width: 170px; - } - - .cast-samurai-walk { - bottom: 8%; - right: 2%; - width: 108px; - } - - .hero-index, .character-crowd__label { - display: none; - } - - .hero-to-paper { - height: 210px; - margin-top: -78px; - } - -} - -/* Home narrative: the creator remains the author and final reviewer. Each - chapter carries one decision from identity to engine delivery. */ -.hero-promise { - display: flex; - flex-wrap: wrap; - gap: 9px; - align-items: center; - margin-top: 32px; - color: rgba(255, 255, 255, 0.5); - font-size: 10px; - font-weight: 650; - letter-spacing: 0.06em; -} - -.hero-promise i { - width: 3px; - height: 3px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.34); -} - -.home-manifesto, .story-section, .home-values, .ecosystem-section, .library-story, .product-footer { - width: min(1360px, calc(100% - 64px)); - margin-right: auto; - margin-left: auto; -} - -.home-manifesto { - display: grid; - grid-template-columns: minmax(0, 0.88fr) minmax(440px, 1.12fr); - gap: clamp(60px, 9vw, 150px); - align-items: end; - padding: 180px 0 190px; -} - -.home-manifesto__copy h2, .story-section__heading h2, .home-values__heading h2, .ecosystem-section h2, .library-story__copy h2 { - margin: 18px 0 24px; - font-family: Georgia, "Songti SC", "Times New Roman", serif; - font-size: clamp(40px, 5vw, 72px); - font-weight: 500; - letter-spacing: -0.055em; - line-height: 1.08; -} - -.home-manifesto__copy p, .story-section__heading p, .ecosystem-section__copy > p, .library-story__copy > p { - margin: 0; - color: var(--muted); - font-size: 14px; - line-height: 1.9; -} - -.home-manifesto__quote { - position: relative; - margin: 0; - padding: 48px 0 0; - border-top: 1px solid var(--ink); -} - -.home-manifesto__quote::before { - position: absolute; - top: -18px; - right: 0; - color: var(--warm); - background: var(--paper); - content: '“'; - font-family: Georgia, serif; - font-size: 68px; - line-height: 1; -} - -.home-manifesto__quote p { - max-width: 720px; - margin: 0; - font-family: Georgia, "Songti SC", serif; - font-size: clamp(30px, 4vw, 54px); - letter-spacing: -0.045em; - line-height: 1.34; -} - -.home-manifesto__quote footer { - margin-top: 34px; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 9px; - letter-spacing: 0.13em; -} - -.story-section { - padding: 36px 0 120px; -} - -.story-section__heading { - max-width: 980px; - margin-bottom: 58px; -} - -.story-section__heading h2 { - max-width: 940px; -} - -.story-section__heading p { - max-width: 680px; -} - -.story-chapter { - display: grid; - grid-template-columns: minmax(320px, 0.7fr) minmax(560px, 1.3fr); - gap: clamp(52px, 8vw, 128px); - align-items: center; - min-height: 760px; - padding: 96px 0; - border-top: 1px solid var(--line-strong); -} - -.story-chapter.is-reversed { - grid-template-columns: minmax(560px, 1.3fr) minmax(320px, 0.7fr); -} - -.story-chapter.is-reversed .story-chapter__copy { - order: 2; -} - -.story-chapter.is-reversed .story-visual { - order: 1; -} - -.story-chapter__copy { - position: relative; - padding-left: 60px; -} - -.story-chapter__number { - position: absolute; - top: -3px; - left: 0; - color: var(--warm); - font-family: Georgia, serif; - font-size: 16px; -} - -.story-chapter__copy h3 { - max-width: 520px; - margin: 17px 0 24px; - font-family: Georgia, "Songti SC", serif; - font-size: clamp(36px, 4vw, 58px); - font-weight: 500; - letter-spacing: -0.052em; - line-height: 1.12; -} - -.story-chapter__copy > p { - max-width: 500px; - margin: 0; - color: var(--muted); - font-size: 14px; - line-height: 1.9; -} - -.story-link { - display: inline-flex; - margin-top: 34px; - padding-bottom: 7px; - border-bottom: 1px solid var(--line-strong); - font-size: 12px; - font-weight: 750; -} - -.story-link:hover, .story-link:focus-visible { - border-color: var(--ink); - outline: none; -} - -.story-visual { - position: relative; - min-height: 610px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 30px; - background: var(--surface); - box-shadow: 0 34px 90px rgba(27, 30, 25, 0.09); -} - -.story-visual img { - image-rendering: pixelated; -} - -.master-stage { - display: grid; - align-items: end; - padding: 50px 38px 95px; - background: radial-gradient(circle at 70% 22%, rgba(200, 151, 85, 0.2), transparent 32%), linear-gradient(145deg, #f4efe5, #e7ebe3); -} - -.master-stage::before, .motion-stage__screen::before, .delivery-stage::before { - position: absolute; - border: 1px solid rgba(23, 24, 23, 0.07); - border-radius: 50%; - content: ''; - pointer-events: none; -} - -.master-stage::before { - top: -150px; - right: -80px; - width: 460px; - height: 460px; -} - -.master-stage__rail { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 14px; - align-items: end; -} - -.master-card { - margin: 0; - overflow: hidden; - border: 1px solid rgba(23, 24, 23, 0.1); - border-radius: 18px; - background: rgba(255, 255, 255, 0.64); - box-shadow: 0 18px 48px rgba(23, 24, 23, 0.08); - transform: translateY(20px) scale(0.92); -} - -.master-card.is-selected { - border-color: var(--accent); - background: var(--workspace-white); - box-shadow: 0 28px 60px rgba(23, 24, 23, 0.14); - transform: translateY(-18px) scale(1); -} - -.master-card img { - display: block; - width: 100%; - aspect-ratio: 1; - object-fit: contain; -} - -.master-card figcaption { - display: grid; - gap: 4px; - padding: 13px 15px 15px; - border-top: 1px solid rgba(23, 24, 23, 0.08); -} - -.master-card figcaption b { - font-size: 12px; -} - -.master-card figcaption span { - color: var(--muted); - font-size: 9px; -} - -.visual-note { - position: absolute; - right: 34px; - bottom: 28px; - left: 34px; - display: flex; - justify-content: space-between; - gap: 18px; - color: var(--muted); - font-size: 10px; -} - -.visual-note span { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - letter-spacing: 0.11em; -} - -.motion-stage { - display: grid; - grid-template-rows: 1fr auto; - padding: 24px; - background: #e9ece6; -} - -.motion-stage__screen { - position: relative; - display: grid; - min-height: 440px; - place-items: center; - overflow: hidden; - border: 1px solid rgba(23, 24, 23, 0.08); - border-radius: 22px; - background: linear-gradient(rgba(38, 63, 45, 0.045) 1px, transparent 1px), linear-gradient(90deg, rgba(38, 63, 45, 0.045) 1px, transparent 1px), #f8f9f5; - background-size: 28px 28px; -} - -.motion-stage__screen::before { - width: 330px; - height: 330px; -} - -.motion-stage__screen > img { - position: relative; - z-index: 2; - width: min(42%, 260px); - filter: drop-shadow(0 22px 22px rgba(20, 24, 18, 0.16)); - animation: character-drift 5.4s ease-in-out infinite; -} - -.motion-stage__axis { - position: absolute; - right: 9%; - bottom: 16%; - left: 9%; - height: 1px; - background: rgba(38, 63, 45, 0.2); -} - -.motion-stage__axis::after { - position: absolute; - top: -3px; - left: 50%; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--accent); - content: ''; -} - -.motion-stage__status { - position: absolute; - top: 22px; - right: 22px; - display: grid; - gap: 5px; - text-align: right; -} - -.motion-stage__status small, .package-label { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 8px; - letter-spacing: 0.12em; -} - -.motion-stage__status b { - font-family: Georgia, serif; - font-size: 18px; - font-weight: 500; -} - -.motion-filmstrip { - display: grid; - grid-template-columns: repeat(8, 1fr); - gap: 7px; - margin-top: 16px; -} - -.motion-filmstrip > span { - position: relative; - display: grid; - overflow: hidden; - border: 1px solid rgba(23, 24, 23, 0.08); - border-radius: 10px; - background: rgba(255, 255, 255, 0.65); -} - -.motion-filmstrip > span.is-current { - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(38, 63, 45, 0.12); -} - -.motion-filmstrip img { - display: block; - width: 100%; -} - -.motion-filmstrip small { - position: absolute; - right: 5px; - bottom: 4px; - color: var(--muted); - font-family: ui-monospace, monospace; - font-size: 7px; -} - -.review-stage { - display: grid; - align-content: start; - gap: 22px; - padding: 34px; - background: #f9f9f6; -} - -.review-stage__heading { - display: flex; - justify-content: space-between; - gap: 20px; - align-items: center; - padding-bottom: 24px; - border-bottom: 1px solid var(--line); -} - -.review-stage__heading > div { - display: grid; - gap: 5px; -} - -.review-stage__heading small, .review-stage__heading span, .review-stage__frames small, .review-stage__decision > span { - color: var(--muted); - font-size: 9px; -} - -.review-stage__heading b { - font-family: Georgia, serif; - font-size: 23px; - font-weight: 500; -} - -.review-stage__heading > span { - padding: 8px 11px; - border-radius: 999px; - color: var(--accent); - background: var(--accent-soft); -} - -.review-stage__frames { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; -} - -.review-stage__frames > span { - position: relative; - display: grid; - border: 1px solid var(--line); - border-radius: 13px; - background: var(--workspace-white); -} - -.review-stage__frames > span.needs-review { - border-color: #b88448; - box-shadow: 0 0 0 3px rgba(184, 132, 72, 0.11); -} - -.review-stage__frames img { - display: block; - width: 100%; -} - -.review-stage__frames small { - padding: 0 10px 9px; -} - -.review-stage__frames .is-approved small { - color: #6b806c; -} - -.review-stage__decision { - display: grid; - grid-template-columns: auto 1fr; - gap: 9px 22px; - align-items: start; - margin-top: 3px; - padding: 24px; - border-radius: 17px; - color: #fff; - background: var(--ink); -} - -.review-stage__decision p { - margin: 0; - color: rgba(255, 255, 255, 0.62); - font-size: 11px; - line-height: 1.7; -} - -.review-stage__decision b { - grid-column: 2; - font-family: Georgia, "Songti SC", serif; - font-size: 16px; - font-weight: 500; - line-height: 1.5; -} - -.delivery-stage { - display: grid; - grid-template-columns: minmax(0, 0.95fr) minmax(280px, 1.05fr); - gap: 18px; - align-items: center; - padding: 42px; - color: #fff; - background: radial-gradient(circle at 14% 20%, rgba(200, 151, 85, 0.15), transparent 31%), #111410; -} - -.delivery-stage::before { - bottom: -140px; - left: -110px; - width: 460px; - height: 460px; - border-color: rgba(255, 255, 255, 0.08); -} - -.delivery-stage__character { - position: relative; - display: grid; - place-items: center; -} - -.delivery-stage__character img { - position: relative; - z-index: 2; - width: min(74%, 280px); - filter: drop-shadow(0 24px 24px rgba(0, 0, 0, 0.55)); - animation: character-drift 6.1s ease-in-out infinite; -} - -.delivery-stage__character b { - position: relative; - z-index: 2; - margin-top: 12px; - font-family: Georgia, "Songti SC", serif; - font-size: 17px; - font-weight: 500; -} - -.delivery-stage__orbit { - position: absolute; - width: 290px; - height: 290px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 50%; -} - -.delivery-stage__orbit::before, .delivery-stage__orbit::after { - position: absolute; - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 50%; - content: ''; -} - -.delivery-stage__orbit::before { - inset: 32px; -} - -.delivery-stage__orbit::after { - inset: 76px; -} - -.delivery-stage__package { - position: relative; - z-index: 2; - padding: 27px; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 20px; - background: rgba(255, 255, 255, 0.055); - backdrop-filter: blur(18px); -} - -.delivery-stage__package .package-label { - display: block; - margin-bottom: 18px; - color: rgba(255, 255, 255, 0.42); -} - -.delivery-stage__package > div { - display: grid; - grid-template-columns: 28px 1fr auto; - gap: 10px; - align-items: center; - min-height: 45px; - border-top: 1px solid rgba(255, 255, 255, 0.1); -} - -.delivery-stage__package i { - color: rgba(255, 255, 255, 0.35); - font-family: Georgia, serif; - font-size: 10px; - font-style: normal; -} - -.delivery-stage__package b { - font-size: 11px; -} - -.delivery-stage__package > div span { - color: #9fb5a0; - font-size: 9px; -} - -.delivery-stage__package footer { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin-top: 20px; -} - -.delivery-stage__package footer span { - padding: 7px 9px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 999px; - color: rgba(255, 255, 255, 0.62); - font-size: 8px; - letter-spacing: 0.08em; -} - -.home-values { - padding: 110px 0 150px; - border-top: 1px solid var(--line-strong); -} - -.home-values__heading { - max-width: 1060px; -} - -.home-values__grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1px; - margin-top: 70px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 24px; - background: var(--line); -} - -.home-values__grid article { - min-height: 340px; - padding: 34px; - background: var(--surface); -} - -.home-values__grid article > span { - color: var(--warm); - font-family: Georgia, serif; - font-size: 15px; -} - -.home-values__grid h3 { - margin: 96px 0 18px; - font-family: Georgia, "Songti SC", serif; - font-size: 26px; - font-weight: 500; -} - -.home-values__grid p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.85; -} - -.ecosystem-section { - display: grid; - grid-template-columns: minmax(0, 0.95fr) minmax(440px, 1.05fr); - gap: clamp(64px, 9vw, 140px); - align-items: center; - padding: clamp(70px, 8vw, 112px); - border-radius: 32px; - color: #fff; - background: radial-gradient(circle at 90% 10%, rgba(200, 151, 85, 0.18), transparent 28%), #172219; - box-shadow: 0 42px 100px rgba(23, 34, 25, 0.18); -} - -.ecosystem-section .overline { - color: rgba(255, 255, 255, 0.44); -} - -.ecosystem-section h2 { - color: #fff; - font-size: clamp(38px, 4.6vw, 64px); -} - -.ecosystem-section__copy > p { - color: rgba(255, 255, 255, 0.58); -} - -.ecosystem-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 32px; -} - -.ecosystem-tags span { - padding: 8px 11px; - border: 1px solid rgba(255, 255, 255, 0.13); - border-radius: 999px; - color: rgba(255, 255, 255, 0.68); - font-size: 9px; -} - -.ecosystem-targets { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; -} - -.ecosystem-targets article { - position: relative; - min-height: 330px; - padding: 28px; - border: 1px solid rgba(255, 255, 255, 0.13); - border-radius: 20px; - background: rgba(255, 255, 255, 0.055); - backdrop-filter: blur(18px); -} - -.ecosystem-targets small { - color: rgba(255, 255, 255, 0.42); - font-size: 9px; -} - -.ecosystem-targets b { - display: block; - margin-top: 64px; - font-family: Georgia, "Songti SC", serif; - font-size: 27px; - font-weight: 500; -} - -.ecosystem-targets p { - margin: 16px 0 0; - color: rgba(255, 255, 255, 0.55); - font-size: 11px; - line-height: 1.75; -} - -.ecosystem-targets article > span { - position: absolute; - bottom: 24px; - left: 28px; - color: #b7c9b8; - font-size: 9px; - font-weight: 700; -} - -.library-story { - display: grid; - grid-template-columns: minmax(520px, 1.12fr) minmax(360px, 0.88fr); - gap: clamp(70px, 10vw, 150px); - align-items: center; - padding: 190px 0; -} - -.library-story__visual { - position: relative; - display: grid; - gap: 12px; - padding: 44px; - border: 1px solid var(--line); - border-radius: 28px; - background: var(--surface); - box-shadow: var(--shadow); -} - -.library-story__line { - position: absolute; - top: 72px; - bottom: 72px; - left: 66px; - width: 1px; - background: var(--line-strong); -} - -.library-node { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: 28px 112px 1fr; - gap: 18px; - align-items: center; - min-height: 82px; - padding: 0 22px 0 10px; - border: 1px solid transparent; - border-radius: 16px; - background: rgba(255, 255, 255, 0.85); -} - -.library-node.is-current { - border-color: #c9d3c8; - background: var(--accent-soft); -} - -.library-node i { - display: grid; - width: 26px; - height: 26px; - place-items: center; - border: 1px solid var(--line-strong); - border-radius: 50%; - color: var(--muted); - background: var(--workspace-white); - font-family: Georgia, serif; - font-size: 8px; - font-style: normal; -} - -.library-node.is-current i { - border-color: var(--accent); - color: #fff; - background: var(--accent); -} - -.library-node b { - font-family: Georgia, "Songti SC", serif; - font-size: 17px; - font-weight: 500; -} - -.library-node span { - color: var(--muted); - font-size: 10px; -} - -.library-story__copy h2 { - font-size: clamp(38px, 4.6vw, 64px); -} - -.home-closing { - align-items: center; - margin-top: 0; - padding: clamp(48px, 7vw, 90px); - border-radius: 32px; - background: radial-gradient(circle at 88% 18%, rgba(200, 151, 85, 0.2), transparent 25%), var(--ink); -} - -.home-closing > div { - max-width: 880px; -} - -.home-closing h2 { - font-size: clamp(38px, 5vw, 68px); -} - -.home-closing p { - margin: 22px 0 0; - color: rgba(255, 255, 255, 0.55); - font-size: 13px; -} - -.product-footer { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 36px; - align-items: center; - padding: 36px 0 54px; - color: var(--muted); -} - -.product-footer .product-brand__mark { - width: 29px; - height: 29px; -} - -.product-footer .product-brand__name { - color: var(--ink); - font-size: 17px; -} - -.product-footer p { - margin: 0; - font-size: 10px; -} - -.product-footer nav { - display: flex; - gap: 22px; - font-size: 10px; -} - -.product-footer a:hover, .product-footer a:focus-visible { - color: var(--ink); - outline: none; -} - -@media (max-width: 1120px) { - .home-manifesto, .ecosystem-section, .library-story { - gap: 60px; - } - - .story-chapter, .story-chapter.is-reversed { - grid-template-columns: minmax(300px, 0.75fr) minmax(480px, 1.25fr); - gap: 50px; - } - - .story-visual { - min-height: 540px; - } - - .delivery-stage { - padding: 28px; - } - - .delivery-stage__orbit { - width: 230px; - height: 230px; - } - -} - -@media (max-width: 900px) { - .home-manifesto, .story-section, .home-values, .ecosystem-section, .library-story, .product-footer { - width: min(100% - 40px, 760px); - } - - .home-manifesto, .ecosystem-section, .library-story { - grid-template-columns: 1fr; - } - - .home-manifesto { - padding: 130px 0; - } - - .story-chapter, .story-chapter.is-reversed { - grid-template-columns: 1fr; - gap: 48px; - min-height: 0; - padding: 80px 0 100px; - } - - .story-chapter.is-reversed .story-chapter__copy, .story-chapter.is-reversed .story-visual { - order: initial; - } - - .story-chapter__copy { - max-width: 620px; - } - - .story-visual { - min-height: 600px; - } - - .home-values__grid { - grid-template-columns: 1fr; - } - - .home-values__grid article { - min-height: 260px; - } - - .home-values__grid h3 { - margin-top: 62px; - } - - .ecosystem-section { - padding: 70px 44px; - } - - .library-story__visual { - order: 2; - } - - .library-story__copy { - order: 1; - } - - .product-footer { - grid-template-columns: auto 1fr; - } - - .product-footer nav { - grid-column: 1 / -1; - } - -} - -@media (max-width: 620px) { - .home-manifesto, .story-section, .home-values, .library-story, .product-footer { - width: calc(100% - 32px); - } - - .hero-promise { - max-width: 280px; - } - - .continue-project { - margin-top: 24px; - } - - .home-manifesto { - gap: 74px; - padding: 110px 0 120px; - } - - .home-manifesto__copy h2, .story-section__heading h2, .home-values__heading h2, .ecosystem-section h2, .library-story__copy h2 { - font-size: 38px; - } - - .home-manifesto__quote p { - font-size: 31px; - } - - .story-section { - padding-bottom: 70px; - } - - .story-chapter { - padding: 68px 0 82px; - } - - .story-chapter__copy { - padding-left: 38px; - } - - .story-chapter__copy h3 { - font-size: 36px; - } - - .story-visual { - min-height: 500px; - border-radius: 22px; - } - - .master-stage { - min-height: 530px; - padding: 35px 18px 80px; - } - - .master-stage__rail { - gap: 7px; - } - - .master-card figcaption { - padding: 9px; - } - - .master-card figcaption span { - display: none; - } - - .visual-note { - right: 18px; - left: 18px; - } - - .motion-stage { - min-height: 520px; - padding: 13px; - } - - .motion-stage__screen { - min-height: 380px; - } - - .motion-filmstrip { - gap: 3px; - } - - .motion-filmstrip small { - display: none; - } - - .review-stage { - min-height: 600px; - padding: 20px; - } - - .review-stage__frames { - grid-template-columns: repeat(4, 1fr); - gap: 5px; - } - - .review-stage__frames small { - display: none; - } - - .review-stage__decision { - grid-template-columns: 1fr; - } - - .review-stage__decision b { - grid-column: 1; - } - - .delivery-stage { - grid-template-columns: 1fr; - min-height: 680px; - padding: 24px; - } - - .delivery-stage__character img { - width: 150px; - } - - .delivery-stage__orbit { - width: 210px; - height: 210px; - } - - .home-values { - padding: 80px 0 110px; - } - - .home-values__grid { - margin-top: 46px; - } - - .ecosystem-section { - width: 100%; - padding: 70px 22px; - border-radius: 0; - } - - .ecosystem-targets { - grid-template-columns: 1fr; - } - - .ecosystem-targets article { - min-height: 260px; - } - - .library-story { - padding: 120px 0; - } - - .library-story__visual { - padding: 22px 14px; - } - - .library-story__line { - left: 32px; - } - - .library-node { - grid-template-columns: 28px 76px 1fr; - gap: 8px; - padding-right: 8px; - } - - .library-node b { - font-size: 14px; - } - - .library-node span { - font-size: 8px; - } - - .home-closing { - display: grid; - margin-bottom: 20px; - padding: 52px 24px; - } - - .home-closing .button { - width: 100%; - } - - .product-footer { - grid-template-columns: 1fr; - gap: 18px; - } - - .product-footer nav { - grid-column: auto; - } - -} - -/* Home act two: each viewport advances one product decision. */ -.hero-to-paper__track { - position: absolute; - right: 8vw; - bottom: 19%; - left: 8vw; - display: grid; - grid-template-columns: auto 1fr auto; - gap: 18px; - align-items: center; - color: rgba(23, 24, 23, 0.42); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 8px; - font-weight: 700; - letter-spacing: 0.18em; -} - -.hero-to-paper__track i { - height: 1px; - background: linear-gradient(90deg, rgba(255, 255, 255, 0.14), rgba(23, 24, 23, 0.28)); -} - -.hero-to-paper__bird-window { - position: absolute; - z-index: 6; - top: 7%; - right: 4vw; - bottom: 8%; - width: min(42vw, 680px); - pointer-events: none; -} - -.hero-to-paper + .continue-project { - width: min(1360px, calc(100% - 64px)); - grid-template-columns: minmax(320px, 0.9fr) minmax(460px, 1.1fr) auto; - padding: 32px 4px; - border-right: 0; - border-left: 0; - border-radius: 0; - background: rgba(223, 227, 223, 0.72); - box-shadow: none; - backdrop-filter: blur(22px); - -webkit-backdrop-filter: blur(22px); -} - -.home-manifesto { - position: relative; - width: 100%; - min-height: 94svh; - align-items: center; - padding: clamp(150px, 18vh, 220px) max(32px, calc((100vw - 1360px) / 2)); - background: radial-gradient(circle at 79% 22%, rgba(200, 151, 85, 0.14), transparent 24%), var(--paper); -} - -.home-manifesto::after { - position: absolute; - top: 9%; - right: max(32px, calc((100vw - 1360px) / 2)); - width: clamp(150px, 21vw, 330px); - aspect-ratio: 1; - border: 1px solid rgba(38, 63, 45, 0.1); - border-radius: 50%; - content: ''; - pointer-events: none; -} - -.home-manifesto__copy, .home-manifesto__quote { - position: relative; - z-index: 1; -} - -.home-manifesto__quote::before { - background: transparent; -} - -.story-section { - width: min(1360px, calc(100% - 64px)); - padding-top: 0; -} - -.story-section__heading { - display: flex; - min-height: 78svh; - flex-direction: column; - justify-content: center; - margin-bottom: 0; -} - -.story-chapter { - position: relative; - min-height: 108svh; - border-top-color: rgba(23, 24, 23, 0.18); -} - -.story-chapter.is-reversed { - background: #ecefe9; - box-shadow: 0 0 0 100vmax #ecefe9; - clip-path: inset(0 -100vmax); -} - -.story-chapter__copy { - position: sticky; - top: 150px; - align-self: start; - margin-top: 130px; - padding-left: 0; -} - -.story-chapter__number { - top: -112px; - left: -8px; - color: rgba(200, 151, 85, 0.18); - font-size: clamp(96px, 11vw, 172px); - line-height: 1; -} - -.story-visual { - min-height: min(72svh, 720px); - border-color: rgba(23, 24, 23, 0.09); - border-radius: 4px 64px 4px 4px; - box-shadow: 0 46px 110px rgba(27, 30, 25, 0.13); -} - -.story-chapter.is-reversed .story-visual { - border-radius: 64px 4px 4px 4px; -} - -.home-values__grid { - gap: 0; - overflow: visible; - border-right: 0; - border-left: 0; - border-radius: 0; - background: transparent; -} - -.home-values__grid article { - min-height: 390px; - padding: 42px; - border-left: 1px solid var(--line); - background: transparent; -} - -.home-values__grid article:first-child { - border-left: 0; -} - -.home-values__grid article > span { - font-size: 58px; - opacity: 0.3; -} - -.ecosystem-section { - width: 100%; - min-height: 92svh; - padding-right: max(32px, calc((100vw - 1360px) / 2)); - padding-left: max(32px, calc((100vw - 1360px) / 2)); - border-radius: 0; - box-shadow: none; -} - -@media (max-width: 900px) { - .hero-to-paper { - height: 62svh; - } - - .hero-to-paper + .continue-project { - width: min(100% - 40px, 760px); - grid-template-columns: 1fr; - margin-top: -54px; - padding: 28px 0; - } - - .home-manifesto { - width: 100%; - min-height: 0; - padding: 140px 32px; - background: var(--paper); - } - - .story-chapter__copy { - position: relative; - top: auto; - margin-top: 80px; - } - - .story-chapter { - min-height: 0; - } - - .ecosystem-section { - padding-right: 44px; - padding-left: 44px; - } - -} - -@media (max-width: 620px) { - .hero-to-paper { - height: 500px; - margin-top: -1px; - } - - .hero-to-paper__track { - right: 22px; - left: 22px; - } - - .hero-to-paper__bird-window { - right: 0; - width: 72vw; - } - - .home-manifesto { - padding: 112px 16px; - } - - .story-section { - width: calc(100% - 32px); - } - - .story-chapter__number { - top: -76px; - font-size: 92px; - } - - .home-values__grid article { - border-top: 1px solid var(--line); - border-left: 0; - } - - .ecosystem-section { - padding-right: 22px; - padding-left: 22px; - } - -} - -@media (prefers-reduced-motion: reduce) { - .hero-to-paper::before, .home-manifesto::before, .story-chapter::before, .story-visual::after, .motion-stage, .master-card, .delivery-stage__orbit, .ecosystem-section::before { - animation: none !important; - } - -} - -/* Ambient motion stays behind the product story. The reserved bird window is - deliberately the only foreground motion lane through the transition. */ -.hero-to-paper::before { - position: absolute; - z-index: 0; - inset: 10% 0 3%; - content: ''; - background-image: radial-gradient(circle, rgba(255, 255, 255, 0.24) 0 1px, transparent 1.5px); - background-size: 28px 28px; - mask-image: linear-gradient(180deg, transparent, #000 24%, #000 64%, transparent 94%); - -webkit-mask-image: linear-gradient(180deg, transparent, #000 24%, #000 64%, transparent 94%); - opacity: 0.34; - animation: ambient-grid-drift 18s linear infinite; -} - -.hero-to-paper__mist--left { - animation: mist-breathe-left 13s ease-in-out infinite alternate; -} - -.hero-to-paper__mist--right { - animation: mist-breathe-right 16s ease-in-out infinite alternate; -} - -.home-manifesto::before { - position: absolute; - inset: 0; - content: ''; - background-image: radial-gradient(circle, rgba(38, 63, 45, 0.12) 0 1px, transparent 1.5px); - background-position: 70% 30%; - background-size: 34px 34px; - mask-image: radial-gradient(circle at 76% 30%, #000, transparent 38%); - -webkit-mask-image: radial-gradient(circle at 76% 30%, #000, transparent 38%); - opacity: 0.5; - animation: ambient-grid-drift 24s linear infinite reverse; - pointer-events: none; -} - -.story-chapter { - isolation: isolate; -} - -.story-chapter::before { - position: absolute; - z-index: 0; - inset: 8% -8% 5%; - content: ''; - background-image: radial-gradient(circle, rgba(38, 63, 45, 0.14) 0 1px, transparent 1.5px); - background-size: 30px 30px; - mask-image: radial-gradient(ellipse at 72% 48%, #000, transparent 57%); - -webkit-mask-image: radial-gradient(ellipse at 72% 48%, #000, transparent 57%); - opacity: 0.3; - animation: chapter-field 15s ease-in-out infinite alternate; - pointer-events: none; -} - -.story-chapter.is-reversed::before { - mask-image: radial-gradient(ellipse at 28% 48%, #000, transparent 57%); - -webkit-mask-image: radial-gradient(ellipse at 28% 48%, #000, transparent 57%); - animation-direction: alternate-reverse; -} - -.story-chapter__copy, .story-visual { - z-index: 1; -} - -.story-visual::after { - position: absolute; - z-index: 8; - inset: -40% 0 auto; - height: 34%; - content: ''; - background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.18), transparent); - mix-blend-mode: soft-light; - animation: stage-scan 9s ease-in-out infinite; - pointer-events: none; -} - -.motion-stage { - animation: grid-pan 20s linear infinite; -} - -.master-card img { - animation: asset-breathe 6s ease-in-out infinite; -} - -.master-card:nth-child(2) img { - animation-delay: -2s; -} - -.master-card:nth-child(3) img { - animation-delay: -4s; -} - -.master-card:nth-child(4) img { - animation-delay: -5s; -} - -.master-card:nth-child(5) img { - animation-delay: -1s; -} - -.review-stage__frames > span.needs-review { - animation: review-attention 3.6s ease-in-out infinite; -} - -.delivery-stage__orbit { - border-top-color: rgba(200, 151, 85, 0.44); - animation: orbit-turn 22s linear infinite; -} - -.ecosystem-section { - position: relative; - isolation: isolate; - overflow: hidden; -} - -.ecosystem-section::before { - position: absolute; - z-index: 0; - top: -35%; - right: -10%; - width: 55vw; - aspect-ratio: 1; - border-radius: 50%; - content: ''; - background: radial-gradient(circle, rgba(200, 151, 85, 0.2), transparent 66%); - filter: blur(24px); - animation: ecosystem-glow 14s ease-in-out infinite alternate; - pointer-events: none; -} - -.ecosystem-section > * { - position: relative; - z-index: 1; -} - -@keyframes ambient-grid-drift { - to { - background-position: 56px 28px; - } - -} - -@keyframes mist-breathe-left { - to { - opacity: 0.5; - transform: translate3d(6vw, -3%, 0) rotate(4deg) scale(1.08); - } - -} - -@keyframes mist-breathe-right { - to { - opacity: 0.72; - transform: translate3d(-5vw, 4%, 0) rotate(-5deg) scale(1.12); - } - -} - -@keyframes chapter-field { - to { - opacity: 0.5; - background-position: 45px 24px; - transform: translate3d(0, -12px, 0); - } - -} - -@keyframes stage-scan { - 0%, 16% { - transform: translateY(0); - opacity: 0; - } - - 32% { - opacity: 0.7; - } - - 66%, 100% { - transform: translateY(520%); - opacity: 0; - } - -} - -@keyframes grid-pan { - to { - background-position: 28px 28px; - } - -} - -@keyframes asset-breathe { - 0%, 100% { - translate: 0 0; - } - - 50% { - translate: 0 -7px; - } - -} - -@keyframes review-attention { - 0%, 100% { - box-shadow: 0 0 0 3px rgba(184, 132, 72, 0.08); - } - - 50% { - box-shadow: 0 0 0 7px rgba(184, 132, 72, 0.16); - } - -} - -@keyframes orbit-turn { - to { - transform: rotate(360deg); - } - -} - -@keyframes ecosystem-glow { - to { - transform: translate3d(-8vw, 12%, 0) scale(1.12); - } - -} - -@media (prefers-reduced-motion: reduce) { - .hero-to-paper__mist, .master-card img, .review-stage__frames > span.needs-review { - animation: none !important; - } - -} - -/* The target-market chapter keeps its original ink-green identity. */ -.ecosystem-section { - background: radial-gradient(ellipse at 88% 10%, rgba(194, 154, 94, 0.12), transparent 30%), radial-gradient(ellipse at 12% 88%, rgba(116, 151, 122, 0.1), transparent 34%), #172219; -} - -.ecosystem-targets article { - overflow: hidden; - background: rgba(255, 255, 255, 0.045); -} - -.ecosystem-target__icon { - position: absolute; - top: 26px; - right: 26px; - width: 48px; - height: 48px; - object-fit: contain; - opacity: 0.82; -} - -.library-story { - background: linear-gradient(180deg, #26352b 0%, #657069 32%, #a4aaa4 66%, #d9dbd6 100%); -} - -@media (max-width: 620px) { - .ecosystem-target__icon { - width: 40px; - height: 40px; - } - -} - -/* The marketing home is one continuous dark stage. Product UI demonstrations - stay light so they read as tangible tools under a focused work light. */ -.product-home { - color: rgba(255, 255, 255, 0.9); - background: #070807; -} - -.hero-to-paper { - background: linear-gradient(180deg, #050505 0%, #080a08 22%, #101510 54%, #151c16 78%, #0b0d0b 100%); -} - -.hero-to-paper__veil { - background: linear-gradient(180deg, transparent, rgba(50, 67, 54, 0.12) 45%, rgba(7, 8, 7, 0.92)); -} - -.hero-to-paper__mist { - background: rgba(77, 98, 80, 0.2); -} - -.hero-to-paper__track { - color: rgba(255, 255, 255, 0.32); -} - -.hero-to-paper__track i { - background: linear-gradient(90deg, rgba(255, 255, 255, 0.08), rgba(150, 174, 153, 0.36), rgba(255, 255, 255, 0.08)); -} - -.hero-to-paper + .continue-project { - border-color: rgba(255, 255, 255, 0.14); - color: rgba(255, 255, 255, 0.88); - background: rgba(13, 16, 13, 0.74); -} - -.hero-to-paper + .continue-project:hover, .hero-to-paper + .continue-project:focus-visible { - border-color: rgba(255, 255, 255, 0.3); -} - -.product-home .continue-project__identity small, .product-home .continue-project__identity p, .product-home .project-progress span { - color: rgba(255, 255, 255, 0.45); -} - -.product-home .project-progress span::before, .product-home .project-progress i { - background: rgba(255, 255, 255, 0.18); -} - -.product-home .project-progress i { - border-color: #101310; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18); -} - -.product-home .project-progress i.is-done { - background: #78947b; - box-shadow: 0 0 0 1px #78947b; -} - -.home-manifesto { - background: radial-gradient(circle at 79% 22%, rgba(200, 151, 85, 0.12), transparent 24%), linear-gradient(132deg, #111611 0 48%, #090b09 48%); -} - -.home-manifesto::after { - border-color: rgba(255, 255, 255, 0.09); -} - -.home-manifesto::before { - background-image: radial-gradient(circle, rgba(159, 181, 161, 0.18) 0 1px, transparent 1.5px); -} - -.product-home .home-manifesto__copy h2, .product-home .story-section__heading h2, .product-home .home-values__heading h2, .product-home .library-story__copy h2 { - color: rgba(255, 255, 255, 0.94); -} - -.product-home .home-manifesto__copy p, .product-home .story-section__heading p, .product-home .story-chapter__copy > p, .product-home .home-values__grid p, .product-home .library-story__copy > p { - color: rgba(255, 255, 255, 0.48); -} - -.product-home .home-manifesto__quote { - border-top-color: rgba(255, 255, 255, 0.42); -} - -.product-home .home-manifesto__quote p { - color: rgba(255, 255, 255, 0.9); -} - -.product-home .home-manifesto__quote footer, .product-home .overline { - color: rgba(255, 255, 255, 0.4); -} - -.story-section { - background: #070807; -} - -.story-chapter { - border-top-color: rgba(255, 255, 255, 0.13); -} - -.story-chapter.is-reversed { - background: #101510; - box-shadow: 0 0 0 100vmax #101510; -} - -.story-chapter::before { - background-image: radial-gradient(circle, rgba(151, 177, 154, 0.17) 0 1px, transparent 1.5px); -} - -.product-home .story-chapter__copy h3, .product-home .home-values__grid h3 { - color: rgba(255, 255, 255, 0.9); -} - -.product-home .story-link { - border-bottom-color: rgba(255, 255, 255, 0.24); - color: rgba(255, 255, 255, 0.8); -} - -.product-home .story-link:hover, .product-home .story-link:focus-visible { - border-bottom-color: rgba(255, 255, 255, 0.72); -} - -.home-values { - border-top-color: rgba(255, 255, 255, 0.13); -} - -.home-values__grid { - border-color: rgba(255, 255, 255, 0.14); -} - -.home-values__grid article { - border-left-color: rgba(255, 255, 255, 0.12); -} - -.library-story { - background: #070807; -} - -.product-footer { - border-top-color: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.42); -} - -.product-footer .product-brand__name, .product-footer a:hover, .product-footer a:focus-visible { - color: rgba(255, 255, 255, 0.88); -} - -@media (max-width: 900px) { - .home-manifesto { - background: linear-gradient(160deg, #111611, #080a08 64%); - } - -} - -@media (max-width: 620px) { - .home-values__grid article { - border-top-color: rgba(255, 255, 255, 0.12); - } - -} - -/* Focused stage lighting: the light UI is revealed by a cone of light instead - of appearing as a flat white rectangle on a dark page. */ -.story-chapter::before { - background-image: radial-gradient(ellipse at 73% 50%, rgba(211, 225, 213, 0.16), transparent 43%), radial-gradient(circle, rgba(151, 177, 154, 0.28) 0 1.25px, transparent 1.9px); - background-size: auto, 24px 24px; - opacity: 0.56; - animation-duration: 10s; -} - -.story-chapter.is-reversed::before { - background-image: radial-gradient(ellipse at 27% 50%, rgba(211, 225, 213, 0.16), transparent 43%), radial-gradient(circle, rgba(151, 177, 154, 0.28) 0 1.25px, transparent 1.9px); -} - -.story-chapter::after { - position: absolute; - z-index: 0; - top: -7%; - right: -2%; - width: 68%; - height: 92%; - content: ''; - background: linear-gradient(180deg, rgba(225, 235, 225, 0.16), rgba(194, 211, 197, 0.07) 60%, transparent); - clip-path: polygon(38% 0, 62% 0, 100% 100%, 0 100%); - filter: blur(28px); - opacity: 0.72; - animation: stage-light-breathe 7s ease-in-out infinite alternate; - pointer-events: none; -} - -.story-chapter.is-reversed::after { - right: auto; - left: -2%; -} - -.story-visual { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.11), 0 0 96px rgba(210, 227, 212, 0.18), 0 52px 140px rgba(0, 0, 0, 0.56); -} - -.story-visual::after { - background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.3), transparent); - animation-duration: 6.8s; -} - -.motion-stage { - animation-duration: 11s; -} - -@keyframes stage-light-breathe { - from { - opacity: 0.48; - transform: translate3d(0, -1.5%, 0) scaleX(0.96); - } - - to { - opacity: 0.82; - transform: translate3d(0, 1.5%, 0) scaleX(1.04); - } - -} - -@media (prefers-reduced-motion: reduce) { - .story-chapter::after { - animation: none !important; - } - -} - -/* Real canvas dot fields replace the nearly invisible CSS texture. */ -.narrative-dot-field { - position: absolute; - z-index: 0; - inset: 0; - width: 100%; - height: 100%; - opacity: 0.96; - pointer-events: none; -} - -.story-chapter::before, .story-chapter.is-reversed::before { - background: radial-gradient(ellipse at 72% 55%, rgba(161, 185, 164, 0.08), transparent 48%); - opacity: 1; - animation: none; -} - -.story-chapter.is-reversed::before { - background: radial-gradient(ellipse at 28% 55%, rgba(161, 185, 164, 0.08), transparent 48%); -} - -/* A soft pool of reflected light sits behind the product window. There is no - literal cone, fixture or visible geometric beam. */ -.story-chapter::after, .story-chapter.is-reversed::after { - top: auto; - right: -8%; - bottom: 5%; - left: auto; - width: 72%; - height: 66%; - border-radius: 50%; - background: radial-gradient(ellipse, rgba(213, 229, 216, 0.18), rgba(126, 153, 131, 0.055) 48%, transparent 72%); - clip-path: none; - filter: blur(48px); - opacity: 0.74; - animation: reflected-light 9s ease-in-out infinite alternate; -} - -.story-chapter.is-reversed::after { - right: auto; - left: -8%; -} - -.story-visual { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 18px 70px rgba(196, 217, 199, 0.11), 0 58px 150px rgba(0, 0, 0, 0.58); -} - -@keyframes reflected-light { - from { - opacity: 0.56; - transform: translate3d(0, 1%, 0) scale(0.96); - } - - to { - opacity: 0.82; - transform: translate3d(0, -2%, 0) scale(1.05); - } - -} - -/* Product characters stand directly on the dark stage. Large pale UI slabs - are removed; only the information needed to explain each workflow step - remains around the character. */ -.story-section__heading { - min-height: 48svh; - padding: 96px 0 74px; -} - -.story-chapter, .story-chapter.is-reversed { - min-height: 86svh; - padding: 74px 0 86px; -} - -.story-chapter__copy { - top: 118px; - margin-top: 92px; -} - -.story-visual, .story-chapter.is-reversed .story-visual { - min-height: min(68svh, 650px); - overflow: visible; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; -} - -.master-stage, .motion-stage, .review-stage, .delivery-stage { - background: transparent; -} - -.master-stage { - padding-bottom: 54px; -} - -.master-stage::before, .motion-stage__screen::before, .delivery-stage::before { - display: none; -} - -.master-card, .master-card.is-selected { - overflow: visible; - border: 0; - background: transparent; - box-shadow: none; -} - -.master-card img { - filter: drop-shadow(0 22px 22px rgba(0, 0, 0, 0.62)); -} - -.master-card figcaption { - border-top: 0; - color: rgba(255, 255, 255, 0.66); - background: rgba(10, 12, 10, 0.52); - backdrop-filter: blur(14px); -} - -.visual-note { - color: rgba(255, 255, 255, 0.42); -} - -.motion-stage { - display: block; - padding: 0; -} - -.motion-stage__screen { - min-height: min(68svh, 650px); - border: 0; - background: transparent; -} - -.motion-stage__screen > img { - width: min(48%, 300px); - filter: drop-shadow(0 28px 28px rgba(0, 0, 0, 0.66)); -} - -.motion-stage__axis { - background: linear-gradient(90deg, transparent, rgba(164, 190, 167, 0.48), transparent); -} - -.motion-filmstrip { - display: none; -} - -.motion-stage__status { - color: rgba(255, 255, 255, 0.76); -} - -.motion-stage__status small { - color: rgba(255, 255, 255, 0.4); -} - -.review-stage { - align-content: center; - padding: 0; -} - -.review-stage__heading { - border-bottom-color: rgba(255, 255, 255, 0.14); - color: rgba(255, 255, 255, 0.76); -} - -.review-stage__frames > span { - border-color: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.54); - background: rgba(255, 255, 255, 0.045); - backdrop-filter: blur(12px); -} - -.review-stage__decision { - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(8, 10, 8, 0.7); - backdrop-filter: blur(18px); -} - -.delivery-stage { - padding: 24px 0; -} - -.delivery-stage__package { - background: rgba(255, 255, 255, 0.045); -} - -/* A genuine soft white spotlight: narrow overhead source, widening through - haze, then dissolving before it reaches the character's light pool. */ -.story-chapter::after, .story-chapter.is-reversed::after { - top: 4%; - right: -3%; - bottom: auto; - left: auto; - width: 68%; - height: 78%; - border-radius: 0; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(240, 247, 240, 0.085) 58%, transparent 96%); - clip-path: polygon(43% 0, 57% 0, 88% 100%, 12% 100%); - filter: blur(34px); - opacity: 0.72; - animation: soft-spotlight 8s ease-in-out infinite alternate; -} - -.story-chapter.is-reversed::after { - right: auto; - left: -3%; -} - -.story-chapter::before, .story-chapter.is-reversed::before { - inset: auto -5% 5% auto; - width: 72%; - height: 36%; - border-radius: 50%; - background: radial-gradient(ellipse, rgba(235, 244, 236, 0.13), rgba(145, 171, 150, 0.035) 48%, transparent 72%); - filter: blur(34px); -} - -.story-chapter.is-reversed::before { - right: auto; - left: -5%; -} - -@keyframes soft-spotlight { - from { - opacity: 0.55; - transform: scaleX(0.97); - } - - to { - opacity: 0.78; - transform: scaleX(1.03); - } - -} - -@media (max-width: 900px) { - .story-section__heading { - min-height: 0; - padding: 110px 0 72px; - } - - .story-chapter, .story-chapter.is-reversed { - min-height: 0; - padding: 88px 0; - } - - .story-chapter__copy { - margin-top: 68px; - } - -} - -/* The narrative is carried by people, light and motion. Decorative diagrams, - divider rules and oversized geometric props are deliberately absent. */ -.home-manifesto, .home-manifesto.is-reversed { - min-height: 72svh; - padding-top: 110px; - padding-bottom: 120px; - background: radial-gradient(ellipse at 68% 48%, rgba(190, 210, 193, 0.075), transparent 38%), #090b09; -} - -.home-manifesto::before, .home-manifesto::after, .home-manifesto__quote::before, .hero-to-paper__track, .motion-stage__axis, .delivery-stage__orbit, .story-visual::after { - display: none; -} - -.home-manifesto__quote { - padding-top: 0; - border-top: 0; -} - -.story-chapter, .story-chapter.is-reversed, .home-values, .product-footer { - border-top: 0; -} - -.story-section__heading { - min-height: 36svh; - padding-top: 72px; - padding-bottom: 52px; -} - -.story-chapter, .story-chapter.is-reversed { - min-height: 76svh; - padding-top: 48px; - padding-bottom: 58px; -} - -.story-chapter__copy { - top: 76px; - margin-top: 54px; -} - -.hero-to-paper__bird-window { - position: fixed; - z-index: 2; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; - background: #000; - opacity: 0; - transform: none; - transition: opacity 120ms linear; - pointer-events: none; - mix-blend-mode: normal; - -webkit-mask-image: none; - mask-image: none; -} - -.scroll-bird__canvas { - display: block; - width: 100%; - height: 100%; -} - -.home-manifesto__copy, .home-manifesto__quote, .story-section__heading, .continue-project { - position: relative; - z-index: 3; -} - -@media (max-width: 900px) { - .home-manifesto { - min-height: 0; - padding: 84px 24px; - } - - .story-section__heading { - padding: 70px 0 38px; - } - - .story-chapter, .story-chapter.is-reversed { - padding: 58px 0; - } - - .story-chapter__copy { - margin-top: 34px; - } - - .hero-to-paper__bird-window { - width: 100%; - } - -} - -@media (prefers-reduced-motion: reduce) { - .hero-to-paper__bird-window { - display: none; - } - -} - -/* Light has no drawn edge: only a quiet bloom around the character remains. */ -.story-chapter::after, .story-chapter.is-reversed::after { - top: 8%; - right: -5%; - bottom: auto; - left: auto; - display: block; - width: 72%; - height: 80%; - border-radius: 50%; - background: radial-gradient(ellipse at 50% 48%, rgba(244, 248, 244, 0.12), rgba(184, 202, 187, 0.045) 36%, transparent 70%); - clip-path: none; - filter: blur(48px); - opacity: 0.9; - animation: reflected-light 10s ease-in-out infinite alternate; -} - -.story-chapter.is-reversed::after { - right: auto; - left: -5%; -} - -.story-chapter::before, .story-chapter.is-reversed::before { - display: none; -} - -.motion-stage__screen, .motion-stage__screen::before, .motion-stage__screen::after { - border: 0; - background: transparent; - box-shadow: none; -} - -/* Home luminance arc: black opening, charcoal narrative, black closing. */ -.product-header { - grid-template-columns: minmax(250px, 1fr) auto minmax(250px, 1fr); -} - -.product-header .product-nav { - grid-column: 2; -} - -.product-home { - background: #1a1c1a; -} - -.product-home .product-hero { - background: #050605; -} - -.hero-to-paper { - background: linear-gradient(180deg, #050605 0%, #080908 18%, #111311 52%, #1a1c1a 86%, #1a1c1a 100%); -} - -.hero-to-paper__veil { - background: linear-gradient(180deg, transparent, rgba(74, 79, 75, 0.08) 48%, rgba(26, 28, 26, 0.92)); -} - -.hero-to-paper__mist { - background: rgba(165, 173, 166, 0.08); -} - -.hero-to-paper + .continue-project { - background: rgba(30, 33, 30, 0.88); -} - -.home-manifesto, .home-manifesto.is-reversed { - background: radial-gradient(ellipse at 68% 48%, rgba(220, 226, 220, 0.055), transparent 40%), #1a1c1a; -} - -.hero-to-paper__bird-window { - background: transparent; - mix-blend-mode: screen; - z-index: 8; -} - -.story-section { - background: linear-gradient(180deg, #1a1c1a 0%, #1d201d 50%, #1b1d1b 100%); -} - -.story-chapter, .story-chapter.is-reversed { - background: transparent; -} - -.story-chapter.is-reversed { - background: #202320; - box-shadow: 0 0 0 100vmax #202320; -} - -.home-values { - background: linear-gradient(180deg, #1b1d1b, #202220); -} - -.home-values__grid { - background: rgba(255, 255, 255, 0.1); -} - -.home-values__grid article { - background: #242724; -} - -.ecosystem-section { - background: radial-gradient(ellipse at 84% 12%, rgba(200, 151, 85, 0.08), transparent 30%), #202320; - box-shadow: none; -} - -.library-story { - position: relative; - padding-bottom: 240px; - background: linear-gradient(180deg, #1b1d1b 0%, #171917 68%, #080908 100%); -} - -.library-story__visual { - border-color: rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.045); - box-shadow: 0 32px 90px rgba(0, 0, 0, 0.22); -} - -.library-node, .library-node.is-current { - color: rgba(255, 255, 255, 0.82); - background: rgba(255, 255, 255, 0.045); -} - -.library-node.is-current { - border-color: rgba(173, 196, 176, 0.28); - background: rgba(130, 157, 134, 0.12); -} - -.library-node i { - border-color: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.52); - background: rgba(255, 255, 255, 0.06); -} - -.library-node span { - color: rgba(255, 255, 255, 0.42); -} - -.home-closing { - position: relative; - background: #050605; - box-shadow: 0 0 0 100vmax #050605; - clip-path: inset(-1px -100vmax); -} - -.home-closing::before { - position: absolute; - right: -100vmax; - bottom: 100%; - left: -100vmax; - height: 180px; - content: ''; - background: linear-gradient(180deg, transparent, #050605); - pointer-events: none; -} - -.product-footer { - color: rgba(255, 255, 255, 0.42); - background: #050605; - box-shadow: 0 0 0 100vmax #050605; - clip-path: inset(0 -100vmax); -} - -.product-footer .product-brand__name { - color: rgba(255, 255, 255, 0.88); -} - -/* A soft exposure change bridges the black brand stage and light workspace. */ -.route-light-transition { - position: fixed; - z-index: 1000; - inset: 0; - overflow: hidden; - background: rgba(5, 6, 5, 0); - opacity: 1; - transition: background-color 520ms cubic-bezier(0.65, 0, 0.35, 1), opacity 620ms cubic-bezier(0.22, 1, 0.36, 1), backdrop-filter 520ms ease; - pointer-events: none; - backdrop-filter: blur(0); -} - -.route-light-transition span { - position: absolute; - inset: -35%; - background: radial-gradient(ellipse at 50% 78%, rgba(223, 227, 223, 1) 0 13%, rgba(223, 227, 223, 0.82) 30%, rgba(223, 227, 223, 0.2) 50%, transparent 70%); - filter: blur(38px); - opacity: 0; - transform: translateY(28%) scale(0.62); - transition: opacity 430ms ease, transform 580ms cubic-bezier(0.2, 0.85, 0.25, 1); -} - -.route-light-transition.is-covering { - background: var(--paper); - backdrop-filter: blur(16px); -} - -.route-light-transition.is-covering span { - opacity: 1; - transform: translateY(0) scale(1.2); -} - -.route-light-transition.is-revealing { - opacity: 0; - backdrop-filter: blur(0); -} - -@media (max-width: 900px) { - .library-story { - padding-bottom: 150px; - } - -} - -@media (prefers-reduced-motion: reduce) { - .route-light-transition { - display: none; - } - -} - -/* The lower half raises exposure in deliberate stages until it meets the - light creation workspace. */ -.home-values { - width: 100%; - padding-right: max(32px, calc((100vw - 1360px) / 2)); - padding-left: max(32px, calc((100vw - 1360px) / 2)); - background: linear-gradient(180deg, #202320 0%, #303330 100%); -} - -.home-values__grid article { - background: #383b38; -} - -.ecosystem-section { - background: radial-gradient(ellipse at 84% 12%, rgba(225, 203, 173, 0.07), transparent 32%), #484c48; -} - -.library-story { - width: 100%; - padding-right: max(32px, calc((100vw - 1360px) / 2)); - padding-left: max(32px, calc((100vw - 1360px) / 2)); - color: #20231f; - background: linear-gradient(180deg, #5c615d 0%, #929791 48%, #d9dbd6 100%); -} - -.product-home .library-story__copy h2, .product-home .library-story__copy > p, .product-home .library-story .overline, .product-home .library-story .story-link { - color: #20231f; -} - -.product-home .library-story .story-link { - border-bottom-color: rgba(32, 35, 31, 0.34); -} - -.library-story__visual { - border-color: rgba(32, 35, 31, 0.12); - background: rgba(247, 248, 244, 0.62); - box-shadow: 0 36px 100px rgba(27, 31, 27, 0.16); - backdrop-filter: blur(24px); -} - -.library-node, .library-node.is-current { - color: #252824; - background: rgba(255, 255, 255, 0.58); -} - -.library-node.is-current { - border-color: rgba(55, 82, 61, 0.2); - background: rgba(226, 235, 226, 0.74); -} - -.library-node span { - color: rgba(32, 35, 31, 0.54); -} - -.home-closing { - width: 100%; - margin-bottom: 0; - padding-right: max(32px, calc((100vw - 1360px) / 2)); - padding-left: max(32px, calc((100vw - 1360px) / 2)); - color: #171917; - background: var(--paper); - box-shadow: 0 0 0 100vmax var(--paper); -} - -.home-closing::before { - height: 260px; - background: linear-gradient(180deg, transparent, rgba(223, 227, 223, 0.48) 40%, var(--paper)); -} - -.home-closing h2 { - color: #171917; -} - -.home-closing p { - color: rgba(23, 25, 23, 0.56); -} - -.home-closing .button--primary { - color: #fff; - background: #1c211d; -} - -.product-footer { - width: 100%; - padding-right: max(32px, calc((100vw - 1360px) / 2)); - padding-left: max(32px, calc((100vw - 1360px) / 2)); - color: rgba(23, 25, 23, 0.48); - background: var(--paper); - box-shadow: 0 0 0 100vmax var(--paper); -} - -.product-footer .product-brand__mark { - background: #1c211d; -} - -.product-footer .product-brand__name { - color: #171917; -} - -@media (max-width: 900px) { - .home-values, .library-story, .home-closing, .product-footer { - padding-right: 24px; - padding-left: 24px; - } - -} - -/* Luminance boundary: the WHY WINDUP statement is the final black scene. - Only the following market section begins the long lift toward white. */ -.product-home, .product-home .product-hero, .hero-to-paper, .home-manifesto, .home-manifesto.is-reversed, .story-section, .story-chapter, .story-chapter.is-reversed, .home-values { - background: #050605; -} - -.hero-to-paper__veil { - background: linear-gradient(180deg, transparent, rgba(5, 6, 5, 0.72) 52%, #050605 100%); -} - -.hero-to-paper__mist { - background: rgba(255, 255, 255, 0.025); -} - -.story-chapter.is-reversed { - box-shadow: 0 0 0 100vmax #050605; -} - -.home-values__grid { - background: rgba(255, 255, 255, 0.055); -} - -.home-values__grid article { - background: #0a0c0a; -} - -.ecosystem-section { - background: radial-gradient(ellipse at 88% 24%, rgba(194, 154, 94, 0.11), transparent 34%), linear-gradient(180deg, #050605 0%, #172219 24%, #2e3931 72%, #465048 100%); -} - -.library-story { - background: linear-gradient(180deg, #465048 0%, #7e8780 36%, #b7bcb6 70%, var(--workspace-white) 100%); -} - -/* Guided production canvas. */ -.production-canvas-workspace { - height: calc(100svh - 76px); - min-height: 620px; - overflow: hidden; - background: var(--workspace-white); -} - -.production-studio { - display: grid; - height: 100%; - grid-template-rows: minmax(0, 1fr); - color: #1b211c; - background: var(--workspace-white); -} - -.production-canvas { - position: relative; - min-width: 0; - min-height: 0; - overflow: hidden; - outline: none; - background: radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.72), transparent 30%), radial-gradient(circle, rgba(51, 68, 56, 0.17) 1px, transparent 1.2px), var(--workspace-white); - background-size: auto, 20px 20px, auto; - cursor: grab; - touch-action: none; -} - -.production-canvas__zoom { - position: absolute; - z-index: 12; - top: 18px; - right: 18px; - display: inline-grid; - grid-template-columns: 34px minmax(54px, auto) 34px; - align-items: center; - overflow: hidden; - border: 1px solid rgba(35, 47, 39, 0.12); - border-radius: 11px; - background: rgba(255, 255, 255, 0.9); - box-shadow: 0 12px 30px rgba(34, 43, 36, 0.1); - backdrop-filter: blur(18px); -} - -.production-canvas__zoom button, .production-canvas__zoom output { - display: grid; - min-height: 34px; - place-items: center; - padding: 0; - color: rgba(28, 38, 31, 0.74); - background: transparent; - font-size: 10px; - font-weight: 700; -} - -.production-canvas__zoom button { - cursor: pointer; -} - -.production-canvas__zoom button:hover, .production-canvas__zoom button:focus-visible { - color: #263f2d; - outline: none; - background: var(--workspace-white); -} - -.production-canvas__zoom output { - border-right: 1px solid rgba(35, 47, 39, 0.1); - border-left: 1px solid rgba(35, 47, 39, 0.1); -} - -.production-canvas:focus-visible { - box-shadow: inset 0 0 0 2px rgba(61, 88, 68, 0.28); -} - -.production-canvas.is-dragging { - cursor: grabbing; -} - -.production-canvas__world { - position: absolute; - top: 0; - left: 0; - width: 3880px; - height: 850px; - transform-origin: 0 0; - will-change: transform; -} - -.production-canvas.is-auto-focusing .production-canvas__world { - transition: transform 430ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.production-canvas__lane { - position: absolute; - top: 386px; - left: 115px; - width: 3560px; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(47, 65, 52, 0.12) 5%, rgba(47, 65, 52, 0.12) 95%, transparent); -} - -.production-node { - position: absolute; - z-index: 2; - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid rgba(32, 44, 35, 0.14); - border-radius: 20px; - color: #1b211c; - background: rgba(250, 251, 248, 0.97); - box-shadow: 0 24px 70px rgba(31, 42, 34, 0.1); - opacity: 0; - transform: translateY(14px) scale(0.96); - transition: opacity 340ms ease, transform 420ms cubic-bezier(0.22, 1, 0.36, 1), border-color 240ms ease, box-shadow 240ms ease; - pointer-events: none; -} - -.production-node.is-revealed { - opacity: 1; - transform: translateY(0) scale(1); - pointer-events: auto; -} - -.production-node.is-current { - border-color: rgba(57, 91, 66, 0.62); - box-shadow: 0 28px 90px rgba(39, 68, 48, 0.17), 0 0 0 4px rgba(72, 108, 80, 0.08); -} - -.production-node.is-done { - border-color: rgba(71, 105, 79, 0.28); -} - -.production-node--identity { - top: 108px; - left: 80px; - width: 470px; - min-height: 560px; - padding: 30px; -} - -.production-node--master { - top: 156px; - left: 700px; - width: 390px; - min-height: 460px; - padding: 28px; -} - -.production-node--action { - top: 195px; - left: 1230px; - width: 340px; - min-height: 385px; - padding: 26px; -} - -.production-node--frames { - top: 122px; - left: 1700px; - width: 520px; - min-height: 520px; - padding: 30px; -} - -.production-node--quality { - top: 172px; - left: 2370px; - width: 360px; - min-height: 420px; - padding: 27px; -} - -.production-node--promote { - top: 198px; - left: 2870px; - width: 360px; - min-height: 390px; - padding: 27px; -} - -.production-node--package { - top: 140px; - left: 3370px; - width: 420px; - min-height: 500px; - padding: 30px; -} - -.production-node__header { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: flex-start; - margin-bottom: 22px; -} - -.production-node__header > div { - display: flex; - gap: 9px; - align-items: center; -} - -.production-node__header span { - color: rgba(35, 48, 39, 0.38); - font-family: Georgia, serif; - font-size: 18px; -} - -.production-node__header small { - color: rgba(35, 48, 39, 0.46); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 8px; - font-weight: 700; - letter-spacing: 0.14em; -} - -.production-node__header > b { - padding: 6px 8px; - border-radius: 999px; - color: rgba(35, 48, 39, 0.48); - background: rgba(35, 48, 39, 0.055); - font-size: 8px; -} - -.production-node.is-current .production-node__header > b { - color: #36533d; - background: #e2ece2; -} - -.production-node.is-done .production-node__header > b { - color: #f4f7f2; - background: #496650; -} - -.production-node > h2 { - margin: 0; - color: #182019; - font-family: Georgia, "Songti SC", serif; - font-size: 27px; - font-weight: 500; - letter-spacing: -0.035em; -} - -.production-node__lead { - margin: 11px 0 20px; - color: rgba(28, 39, 31, 0.54); - font-size: 10px; - line-height: 1.75; -} - -.demo-source-grid { - display: grid; - gap: 10px; -} - -.demo-source-card { - display: grid; - grid-template-columns: 36px 1fr auto; - gap: 4px 12px; - align-items: center; - width: 100%; - padding: 15px; - border: 1px solid rgba(32, 44, 35, 0.12); - border-radius: 13px; - color: #1d271f; - background: #f1f3ee; - text-align: left; - cursor: pointer; - transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; -} - -.demo-source-card:hover, .demo-source-card:focus-visible { - border-color: rgba(54, 87, 63, 0.46); - outline: none; - background: var(--workspace-white); - transform: translateY(-1px); -} - -.demo-source-card > span { - grid-row: 1 / span 3; - color: rgba(33, 48, 38, 0.32); - font-family: Georgia, serif; - font-size: 22px; -} - -.demo-source-card small { - color: rgba(33, 48, 38, 0.42); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 7px; - font-weight: 700; - letter-spacing: 0.12em; -} - -.demo-source-card h3 { - margin: 0; - font-size: 12px; -} - -.demo-source-card p { - grid-column: 2 / -1; - margin: 2px 0 0; - color: rgba(28, 39, 31, 0.55); - font-size: 8px; - line-height: 1.55; -} - -.demo-source-card > b { - grid-column: 3; - grid-row: 1 / span 2; - color: #43614b; - font-size: 8px; -} - -.demo-source-summary { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 6px 12px; - align-items: center; - margin: 13px 0 16px; - padding: 11px 12px; - border-radius: 11px; - background: #e8eee7; -} - -.demo-source-summary > span { - display: grid; - gap: 2px; -} - -.demo-source-summary small { - color: rgba(33, 48, 38, 0.48); - font-size: 7px; -} - -.demo-source-summary b { - font-size: 9px; -} - -.demo-source-summary p { - margin: 0; - color: rgba(33, 48, 38, 0.56); - font-size: 8px; - line-height: 1.5; -} - -.demo-source-summary button { - border: 0; - color: #36533d; - background: transparent; - font-size: 8px; - font-weight: 700; - cursor: pointer; -} - -.production-identity-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 13px; -} - -.production-identity-fields label { - display: grid; - gap: 6px; -} - -.production-identity-fields label:nth-child(n + 3) { - grid-column: 1 / -1; -} - -.production-identity-fields label > span { - color: rgba(28, 39, 31, 0.56); - font-size: 8px; - font-weight: 700; -} - -.production-identity-fields input, .production-identity-fields textarea { - width: 100%; - border: 1px solid rgba(32, 44, 35, 0.12); - border-radius: 10px; - outline: none; - color: #1a211b; - background: #f1f3ee; - font: inherit; - font-size: 10px; -} - -.production-identity-fields input { - height: 39px; - padding: 0 11px; -} - -.production-identity-fields textarea { - min-height: 62px; - padding: 10px 11px; - resize: none; - line-height: 1.55; -} - -.production-identity-fields input:focus, .production-identity-fields textarea:focus { - border-color: rgba(53, 87, 62, 0.46); - background: var(--workspace-white); - box-shadow: 0 0 0 3px rgba(61, 98, 70, 0.08); -} - -.production-identity-fields :disabled { - color: rgba(25, 34, 27, 0.58); - opacity: 1; -} - -.production-node__action { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: center; - margin-top: auto; - padding-top: 18px; - border-top: 1px solid rgba(32, 44, 35, 0.1); -} - -.production-node__action > span { - display: grid; - gap: 4px; -} - -.production-node__action b { - font-size: 9px; -} - -.production-node__action small { - color: rgba(28, 39, 31, 0.48); - font-size: 8px; -} - -.production-node__action .button { - min-width: 120px; - min-height: 38px; - padding: 0 14px; - font-size: 9px; -} - -.production-master-preview { - position: relative; - display: grid; - min-height: 270px; - margin-top: 22px; - place-items: center; - overflow: hidden; - border-radius: 15px; - background: #18231b; -} - -.production-master-preview__grid { - position: absolute; - inset: 12%; - background-image: radial-gradient(rgba(207, 224, 210, 0.26) 1px, transparent 1px); - background-size: 10px 10px; - mask-image: radial-gradient(circle, #000, transparent 72%); -} - -.production-master-preview img { - z-index: 1; - width: 190px; - height: 220px; - object-fit: contain; - image-rendering: pixelated; - filter: drop-shadow(0 16px 18px rgba(0, 0, 0, 0.32)); -} - -.production-master-preview small { - position: absolute; - right: 14px; - bottom: 12px; - left: 14px; - padding-top: 7px; - border-top: 1px solid rgba(224, 234, 225, 0.18); - color: rgba(224, 234, 225, 0.42); - font-family: monospace; - font-size: 7px; - text-align: right; -} - -.production-specs { - display: grid; - grid-template-columns: repeat(3, 1fr); - margin: 16px 0 0; -} - -.production-specs div { - display: grid; - gap: 4px; -} - -.production-specs dt { - color: rgba(28, 39, 31, 0.42); - font-size: 7px; -} - -.production-specs dd { - margin: 0; - color: #27342a; - font-size: 8px; - font-weight: 700; -} - -.production-action-preview { - display: grid; - min-height: 190px; - margin: 20px 0 14px; - place-items: center; - border-radius: 14px; - background: radial-gradient(circle, rgba(54, 78, 60, 0.12) 1px, transparent 1px), #edf0ea; - background-size: 11px 11px; -} - -.production-action-preview img { - width: 150px; - height: 160px; - object-fit: contain; - image-rendering: pixelated; -} - -.production-action-preview--pair { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.production-action-preview--pair figure { - display: grid; - margin: 0; - place-items: center; -} - -.production-action-preview--pair img { - width: 116px; - height: 140px; -} - -.production-action-preview--pair figcaption { - color: rgba(28, 39, 31, 0.42); - font-family: monospace; - font-size: 7px; - letter-spacing: 0.12em; -} - -.production-action-facts { - display: grid; - gap: 8px; -} - -.production-action-facts > span, .production-deliverables li { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; - padding-bottom: 7px; - border-bottom: 1px solid rgba(32, 44, 35, 0.09); -} - -.production-action-facts small, .production-deliverables span { - color: rgba(28, 39, 31, 0.45); - font-size: 8px; -} - -.production-action-facts b, .production-deliverables b { - font-size: 8px; -} - -.production-frame-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - margin-top: 22px; -} - -.production-frame-grid > span { - position: relative; - display: grid; - min-height: 118px; - place-items: center; - border: 1px solid rgba(32, 44, 35, 0.1); - border-radius: 11px; - background: #eef1eb; -} - -.production-frame-grid img { - width: 92px; - height: 92px; - object-fit: contain; - image-rendering: pixelated; -} - -.production-frame-grid small { - position: absolute; - right: 7px; - bottom: 5px; - color: rgba(29, 40, 32, 0.36); - font-family: monospace; - font-size: 7px; -} - -.production-node__meta { - display: flex; - gap: 6px; - margin-top: 14px; -} - -.production-node__meta span { - padding: 5px 7px; - border-radius: 999px; - color: rgba(35, 49, 39, 0.52); - background: #edf1eb; - font-size: 7px; - font-weight: 700; -} - -.production-quality-score { - display: flex; - gap: 12px; - align-items: flex-end; - margin: 25px 0 18px; -} - -.production-quality-score strong { - color: #34543d; - font-family: Georgia, serif; - font-size: 46px; - font-weight: 400; - line-height: 0.85; -} - -.production-quality-score span { - color: rgba(28, 39, 31, 0.46); - font-size: 8px; -} - -.production-quality-list, .production-deliverables { - display: grid; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; -} - -.production-quality-list li { - display: grid; - grid-template-columns: 18px 1fr auto; - gap: 8px; - align-items: center; - min-height: 28px; - border-bottom: 1px solid rgba(32, 44, 35, 0.08); -} - -.production-quality-list i { - display: grid; - width: 17px; - height: 17px; - place-items: center; - border-radius: 50%; - color: #fff; - background: #52705a; - font-size: 7px; - font-style: normal; -} - -.production-quality-list span { - color: rgba(28, 39, 31, 0.68); - font-size: 8px; -} - -.production-quality-list b { - color: #4c6b54; - font-size: 7px; -} - -.production-node__link { - width: fit-content; - margin-top: auto; - padding-top: 16px; - color: #35543e; - font-size: 8px; - font-weight: 700; -} - -.production-version-stack { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 10px; - align-items: center; - margin: 24px 0 18px; -} - -.production-version-stack figure { - overflow: hidden; - margin: 0; - border: 1px solid rgba(32, 44, 35, 0.1); - border-radius: 12px; - background: #edf0ea; -} - -.production-version-stack figure.is-formal { - border-color: rgba(65, 100, 74, 0.4); -} - -.production-version-stack img { - display: block; - width: 100%; - height: 120px; - object-fit: contain; - image-rendering: pixelated; -} - -.production-version-stack figcaption { - padding: 8px; - border-top: 1px solid rgba(32, 44, 35, 0.09); - color: rgba(28, 39, 31, 0.5); - background: rgba(255, 255, 255, 0.72); - font-size: 7px; - text-align: center; -} - -.production-version-stack > span { - color: rgba(34, 48, 38, 0.34); - font-size: 12px; -} - -.production-adoption-note { - display: block; - padding: 11px 12px; - border-radius: 10px; - color: #46604d; - background: #e8eee7; - font-size: 8px; - line-height: 1.5; -} - -.production-targets { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px; - margin: 24px 0 20px; -} - -.production-targets > span { - display: grid; - min-height: 90px; - place-items: center; - border: 1px solid rgba(32, 44, 35, 0.1); - border-radius: 12px; - background: #eef1eb; -} - -.production-targets img { - width: 28px; - height: 28px; -} - -.production-targets b { - margin-top: -16px; - font-size: 8px; -} - -.production-result-actions { - display: none; - flex-wrap: wrap; - gap: 7px; - margin-top: 18px; -} - -.production-result-actions.is-visible { - display: flex; -} - -.production-result-actions .button { - min-height: 34px; - padding: 0 12px; - font-size: 8px; -} - -.production-result-actions .production-node__link { - margin: 0 0 0 auto; - padding: 10px 0 0; -} - -.production-connector { - position: absolute; - z-index: 1; - top: 386px; - height: 1px; - background: rgba(51, 73, 58, 0.28); - opacity: 0; - transform: scaleX(0); - transform-origin: left; - transition: opacity 240ms ease, transform 480ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.production-connector::after { - position: absolute; - top: -3px; - right: -1px; - width: 7px; - height: 7px; - border-top: 1px solid rgba(51, 73, 58, 0.5); - border-right: 1px solid rgba(51, 73, 58, 0.5); - content: ''; - transform: rotate(45deg); -} - -.production-connector.is-revealed { - opacity: 1; - transform: scaleX(1); -} - -.production-connector.is-done { - background: rgba(53, 90, 64, 0.65); -} - -.production-connector--1 { - left: 550px; - width: 150px; -} - -.production-connector--2 { - left: 1090px; - width: 140px; -} - -.production-connector--3 { - left: 1570px; - width: 130px; -} - -.production-connector--4 { - left: 2220px; - width: 150px; -} - -.production-connector--5 { - left: 2730px; - width: 140px; -} - -.production-connector--6 { - left: 3230px; - width: 140px; -} - -.production-canvas__progress { - position: absolute; - z-index: 8; - bottom: 20px; - left: 22px; - display: grid; - width: 210px; - gap: 9px; - padding: 12px 14px; - border: 1px solid rgba(36, 48, 40, 0.12); - border-radius: 12px; - background: rgba(249, 250, 247, 0.88); - box-shadow: 0 14px 34px rgba(31, 42, 34, 0.08); - backdrop-filter: blur(16px); -} - -.production-canvas__progress > span { - display: flex; - justify-content: space-between; - align-items: baseline; -} - -.production-canvas__progress b { - color: #294330; - font-family: Georgia, serif; - font-size: 17px; - font-weight: 500; -} - -.production-canvas__progress small { - color: rgba(35, 48, 39, 0.42); - font-family: monospace; - font-size: 7px; - letter-spacing: 0.12em; -} - -.production-canvas__progress > i { - height: 3px; - overflow: hidden; - border-radius: 99px; - background: rgba(42, 63, 48, 0.1); -} - -.production-canvas__progress em { - display: block; - width: 0; - height: 100%; - border-radius: inherit; - background: #53735c; - transition: width 420ms ease; -} - -.production-canvas__map { - position: absolute; - z-index: 8; - right: 22px; - bottom: 20px; - display: flex; - gap: 5px; - padding: 9px; - border: 1px solid rgba(36, 48, 40, 0.12); - border-radius: 12px; - background: rgba(249, 250, 247, 0.88); - box-shadow: 0 14px 34px rgba(31, 42, 34, 0.08); - backdrop-filter: blur(16px); -} - -.production-canvas__map button { - display: grid; - width: 27px; - height: 27px; - place-items: center; - border-radius: 7px; - color: rgba(35, 48, 39, 0.3); - background: rgba(38, 53, 42, 0.055); - cursor: pointer; - font-family: monospace; - font-size: 7px; -} - -.production-canvas__map button.is-revealed { - color: rgba(35, 48, 39, 0.72); - background: #e8ede6; -} - -.production-canvas__map button.is-current { - color: #fff; - background: #3e6148; -} - -.production-canvas__map button.is-done { - color: #31503a; - background: #d5e2d5; -} - -.production-canvas__map button:disabled { - cursor: default; -} - -.production-canvas__hint { - position: absolute; - z-index: 7; - bottom: 29px; - left: 50%; - color: rgba(33, 46, 37, 0.38); - font-size: 8px; - transform: translateX(-50%); - white-space: nowrap; -} - -/* Assets materialize as dot fields, resolve from soft previews, and settle one result at a time. */ -.production-canvas .production-node.is-current .production-node__header > b { - display: inline-flex; - gap: 6px; - align-items: center; -} - -.production-canvas .production-node.is-current .production-node__header > b::before { - width: 5px; - height: 5px; - border-radius: 50%; - background: #567a60; - box-shadow: 0 0 0 0 rgba(86, 122, 96, 0.3); - content: ''; - animation: canvas-processing-pulse 1.1s ease-out infinite; -} - -.production-node--master .production-master-preview::after, .production-node--action .production-action-preview figure::after, .production-node--frames .production-frame-grid > span::after, .production-node--promote .production-version-stack figure::after { - position: absolute; - z-index: 3; - inset: 0; - border-radius: inherit; - background-image: radial-gradient(circle, rgba(198, 218, 202, 0.78) 1.2px, transparent 1.5px); - background-position: 0 0; - background-size: 7px 7px; - content: ''; - opacity: 0; - pointer-events: none; - mask-image: radial-gradient(circle at 50% 50%, #000 18%, rgba(0, 0, 0, 0.86) 52%, transparent 88%); - -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 18%, rgba(0, 0, 0, 0.86) 52%, transparent 88%); -} - -.production-node--master.is-current .production-master-preview::after { - animation: canvas-dot-materialize 1.85s ease-out 80ms both; -} - -.production-node--master.is-current .production-master-preview img { - animation: canvas-master-resolve 2.15s cubic-bezier(0.22, 1, 0.36, 1) 90ms both; -} - -.production-action-preview--pair figure { - position: relative; - overflow: hidden; - border-radius: 11px; -} - -.production-node--action.is-current .production-action-preview figure::after { - animation: canvas-dot-materialize 1.55s ease-out 100ms both; -} - -.production-node--action.is-current .production-action-preview figure:nth-child(2)::after { - animation-delay: 560ms; -} - -.production-node--action.is-current .production-action-preview img { - animation: canvas-image-resolve 1.8s cubic-bezier(0.22, 1, 0.36, 1) 120ms both; -} - -.production-node--action.is-current .production-action-preview figure:nth-child(2) img { - animation-delay: 580ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(1) { - --frame-delay: 80ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(2) { - --frame-delay: 250ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(3) { - --frame-delay: 420ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(4) { - --frame-delay: 590ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(5) { - --frame-delay: 760ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(6) { - --frame-delay: 930ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(7) { - --frame-delay: 1100ms; -} - -.production-node--frames .production-frame-grid > span:nth-child(8) { - --frame-delay: 1270ms; -} - -.production-node--frames.is-current .production-frame-grid > span::after { - animation: canvas-frame-dots 980ms ease-out var(--frame-delay) both; -} - -.production-node--frames.is-current .production-frame-grid img { - animation: canvas-frame-resolve 1.08s cubic-bezier(0.22, 1, 0.36, 1) var(--frame-delay) both; -} - -.production-node--quality.is-current .production-quality-score, .production-node--quality.is-current .production-quality-list li, .production-node--package.is-current .production-targets > span, .production-node--package.is-current .production-deliverables li { - opacity: 0; - transform: translate3d(0, 7px, 0); - animation: canvas-item-settle 420ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(1) { - animation-delay: 120ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(2) { - animation-delay: 340ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(3) { - animation-delay: 560ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(4) { - animation-delay: 780ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(5) { - animation-delay: 1000ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(6) { - animation-delay: 1220ms; -} - -.production-node--quality.is-current .production-quality-score { - animation-delay: 1640ms; -} - -.production-node--quality.is-current .production-quality-list li i { - animation: canvas-check-confirm 360ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(1) i { - animation-delay: 250ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(2) i { - animation-delay: 470ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(3) i { - animation-delay: 690ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(4) i { - animation-delay: 910ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(5) i { - animation-delay: 1130ms; -} - -.production-node--quality.is-current .production-quality-list li:nth-child(6) i { - animation-delay: 1350ms; -} - -.production-node--promote .production-version-stack figure { - position: relative; -} - -.production-node--promote.is-current .production-version-stack figure:first-child::after { - animation: canvas-dot-materialize 1.25s ease-out 80ms both; -} - -.production-node--promote.is-current .production-version-stack figure:first-child img { - animation: canvas-image-resolve 1.35s cubic-bezier(0.22, 1, 0.36, 1) 100ms both; -} - -.production-node--promote.is-current .production-version-stack > span { - animation: canvas-arrow-transfer 520ms cubic-bezier(0.22, 1, 0.36, 1) 900ms both; -} - -.production-node--promote.is-current .production-version-stack figure.is-formal::after { - animation: canvas-dot-materialize 1.2s ease-out 1050ms both; -} - -.production-node--promote.is-current .production-version-stack figure.is-formal img, .production-node--promote.is-current .production-adoption-note { - animation: canvas-image-resolve 1.2s cubic-bezier(0.22, 1, 0.36, 1) 1100ms both; -} - -.production-node--package.is-current .production-targets > span:nth-child(1) { - animation-delay: 120ms; -} - -.production-node--package.is-current .production-targets > span:nth-child(2) { - animation-delay: 420ms; -} - -.production-node--package.is-current .production-deliverables li:nth-child(1) { - animation-delay: 760ms; -} - -.production-node--package.is-current .production-deliverables li:nth-child(2) { - animation-delay: 980ms; -} - -.production-node--package.is-current .production-deliverables li:nth-child(3) { - animation-delay: 1200ms; -} - -.production-node--package.is-current .production-deliverables li:nth-child(4) { - animation-delay: 1420ms; -} - -.production-studio[data-production-status='running'] .production-canvas__progress em { - position: relative; - overflow: hidden; -} - -.production-studio[data-production-status='running'] .production-canvas__progress em::after { - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.78), transparent); - content: ''; - animation: canvas-progress-breathe 1.8s ease-in-out infinite; - transform: translateX(-100%); -} - -@keyframes canvas-processing-pulse { - 70%, 100% { - box-shadow: 0 0 0 6px rgba(86, 122, 96, 0); - } - -} - -@keyframes canvas-dot-materialize { - 0% { - opacity: 0; - transform: scale(0.94); - } - - 22% { - opacity: 0.92; - transform: scale(0.98); - } - - 58% { - opacity: 0.58; - background-position: 8px 4px; - transform: scale(1); - } - - 100% { - opacity: 0; - background-position: 14px 7px; - transform: scale(1.015); - } - -} - -@keyframes canvas-frame-dots { - 0% { - opacity: 0; - transform: scale(0.88); - } - - 28% { - opacity: 0.9; - transform: scale(0.96); - } - - 62% { - opacity: 0.5; - transform: scale(1); - } - - 100% { - opacity: 0; - transform: scale(1.02); - } - -} - -@keyframes canvas-image-resolve { - 0% { - opacity: 0.06; - filter: blur(16px) saturate(0.2) contrast(0.65); - transform: scale(0.94); - } - - 38% { - opacity: 0.42; - filter: blur(9px) saturate(0.5) contrast(0.78); - } - - 72% { - opacity: 0.88; - filter: blur(2.5px) saturate(0.9) contrast(0.95); - } - - 100% { - opacity: 1; - filter: blur(0) saturate(1) contrast(1); - transform: scale(1); - } - -} - -@keyframes canvas-master-resolve { - 0% { - opacity: 0.05; - filter: blur(18px) saturate(0.2) drop-shadow(0 16px 18px rgba(0, 0, 0, 0)); - transform: scale(0.92); - } - - 42% { - opacity: 0.48; - filter: blur(9px) saturate(0.56) drop-shadow(0 16px 18px rgba(0, 0, 0, 0.12)); - } - - 76% { - opacity: 0.9; - filter: blur(2px) saturate(0.92) drop-shadow(0 16px 18px rgba(0, 0, 0, 0.26)); - } - - 100% { - opacity: 1; - filter: blur(0) saturate(1) drop-shadow(0 16px 18px rgba(0, 0, 0, 0.32)); - transform: scale(1); - } - -} - -@keyframes canvas-frame-resolve { - 0% { - opacity: 0.04; - filter: blur(11px) saturate(0.25); - transform: scale(0.88); - } - - 48% { - opacity: 0.58; - filter: blur(4px) saturate(0.68); - } - - 100% { - opacity: 1; - filter: blur(0) saturate(1); - transform: scale(1); - } - -} - -@keyframes canvas-item-settle { - to { - opacity: 1; - transform: translate3d(0, 0, 0); - } - -} - -@keyframes canvas-check-confirm { - 0% { - opacity: 0; - transform: scale(0.3); - } - - 70% { - transform: scale(1.14); - } - - 100% { - opacity: 1; - transform: scale(1); - } - -} - -@keyframes canvas-arrow-transfer { - 0% { - opacity: 0.12; - transform: translateX(-7px); - } - - 100% { - opacity: 1; - transform: translateX(0); - } - -} - -@keyframes canvas-progress-breathe { - 55%, 100% { - transform: translateX(100%); - } - -} - -@media (prefers-reduced-motion: reduce) { - .production-canvas .production-node.is-current img, .production-canvas .production-node.is-current li, .production-canvas .production-node.is-current figure, .production-canvas .production-node.is-current .production-quality-score, .production-canvas .production-node.is-current .production-adoption-note, .production-canvas .production-node.is-current .production-targets > span { - opacity: 1; - animation: none !important; - filter: none; - transform: none; - } - - .production-canvas .production-node.is-current *::after, .production-canvas .production-node.is-current *::before, .production-studio[data-production-status='running'] .production-canvas__progress em::after { - display: none; - animation: none !important; - } - -} - -@media (max-width: 760px) { - .production-canvas-workspace { - height: calc(100svh - 68px); - min-height: 560px; - } - - .production-studio { - grid-template-rows: minmax(0, 1fr); - } - - .production-node--identity { - width: 420px; - } - - .production-canvas__progress { - bottom: 14px; - left: 14px; - width: 150px; - } - - .production-canvas__map { - right: 14px; - bottom: 14px; - } - - .production-canvas__map button { - width: 23px; - height: 23px; - } - -} - -/* Creation workbench */ -.production-canvas-workspace { - min-height: 720px; - background: #e8eae6; -} - -.creation-workbench { - display: grid; - height: 100%; - grid-template-rows: 62px minmax(0, 1fr) 156px; - color: #1c211d; - background: #e8eae6; -} - -.workbench-flow { - position: relative; - z-index: 4; - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - margin: 0; - padding: 0 clamp(22px, 3vw, 42px); - border-bottom: 1px solid rgba(28, 37, 30, 0.1); - background: rgba(248, 249, 246, 0.96); - list-style: none; -} - -.workbench-flow li { - position: relative; - display: flex; - gap: 9px; - align-items: center; - padding: 0 14px; - color: #9a9e99; -} - -.workbench-flow li:not(:last-child)::after { - position: absolute; - top: 50%; - right: 0; - width: 28px; - height: 1px; - background: #d4d7d2; - content: ''; -} - -.workbench-flow i { - display: grid; - width: 24px; - height: 24px; - flex: 0 0 auto; - place-items: center; - border: 1px solid #d3d6d1; - border-radius: 50%; - background: var(--workspace-white); - font-size: 9px; - font-style: normal; - font-weight: 750; -} - -.workbench-flow span { - display: grid; - gap: 2px; -} - -.workbench-flow b { - font-size: 10px; -} - -.workbench-flow small { - font-size: 8px; -} - -.workbench-flow li.is-done { - color: #57705d; -} - -.workbench-flow li.is-done i { - border-color: #b8c8ba; - background: #dfe9df; -} - -.workbench-flow li.is-current { - color: #203526; -} - -.workbench-flow li.is-current i { - border-color: #34543d; - color: #fff; - background: #34543d; - box-shadow: 0 0 0 4px rgba(52, 84, 61, 0.09); -} - -.creation-workbench__body { - display: grid; - min-height: 0; - grid-template-columns: 232px minmax(560px, 1fr) 280px; -} - -.workbench-assets, .workbench-inspector { - min-width: 0; - overflow: auto; - background: rgba(246, 247, 244, 0.94); -} - -.workbench-assets { - border-right: 1px solid rgba(28, 37, 30, 0.1); -} - -.workbench-inspector { - border-left: 1px solid rgba(28, 37, 30, 0.1); -} - -.workbench-assets > header, .workbench-inspector > header { - display: grid; - gap: 4px; - padding: 20px 18px 16px; - border-bottom: 1px solid rgba(28, 37, 30, 0.08); -} - -.workbench-assets h2, .workbench-inspector h2 { - margin: 0; - font-family: Georgia, "Songti SC", serif; - font-size: 18px; - font-weight: 500; -} - -.workbench-assets > header small { - color: #929791; - font-size: 8px; -} - -.workbench-assets__tree { - display: grid; - gap: 4px; - padding: 10px; -} - -.workbench-asset { - display: grid; - grid-template-columns: 38px minmax(0, 1fr) auto; - gap: 9px; - align-items: center; - width: 100%; - padding: 8px; - border: 1px solid transparent; - border-radius: 10px; - background: transparent; - text-align: left; - cursor: pointer; -} - -.workbench-asset:hover { - background: var(--workspace-white); -} - -.workbench-asset.is-active { - border-color: #d9ddd7; - background: var(--workspace-white); - box-shadow: 0 7px 18px rgba(30, 40, 32, 0.05); -} - -.workbench-asset img { - width: 38px; - height: 38px; - object-fit: contain; - border-radius: 8px; - background: #eceeea; - image-rendering: pixelated; -} - -.workbench-asset span { - display: grid; - min-width: 0; - gap: 3px; -} - -.workbench-asset b { - overflow: hidden; - font-size: 9px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.workbench-asset small { - color: #8b908a; - font-size: 7px; -} - -.workbench-asset > i { - color: #adb1ac; - font-style: normal; -} - -.workbench-assets__empty { - padding: 12px; - color: #8d928c; - font-size: 9px; -} - -.workbench-assets__draft { - display: grid; - gap: 5px; - margin: 4px 10px; - padding: 12px; - border: 1px dashed #c7cbc5; - border-radius: 11px; - background: rgba(255, 255, 255, 0.46); -} - -.workbench-assets__draft span { - display: flex; - gap: 7px; - align-items: center; -} - -.workbench-assets__draft i { - color: #597260; - font-style: normal; -} - -.workbench-assets__draft b { - font-size: 9px; -} - -.workbench-assets__draft small { - color: #929791; - font-size: 7px; -} - -.workbench-canvas { - position: relative; - display: grid; - min-width: 0; - min-height: 0; - grid-template-rows: 46px minmax(0, 1fr); - background: radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.74), transparent 42%), radial-gradient(circle, rgba(51, 68, 56, 0.14) 1px, transparent 1.2px), #e1e5e0; - background-size: auto, 20px 20px, auto; -} - -.workbench-canvas__toolbar { - position: relative; - z-index: 3; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 14px 0 18px; - border-bottom: 1px solid rgba(32, 43, 35, 0.09); - background: rgba(248, 249, 246, 0.8); - backdrop-filter: blur(16px); -} - -.workbench-canvas__toolbar > span { - display: flex; - gap: 9px; - align-items: baseline; -} - -.workbench-canvas__toolbar b { - font-size: 10px; -} - -.workbench-canvas__toolbar small { - color: #858b84; - font-size: 8px; -} - -.workbench-canvas__toolbar .production-canvas__zoom { - position: static; - box-shadow: 0 4px 12px rgba(34, 43, 36, 0.05); -} - -.workbench-canvas__viewport { - display: grid; - min-height: 0; - place-items: center; - overflow: auto; - padding: 28px; -} - -.workbench-canvas__content { - width: min(820px, 100%); - transform: scale(var(--workbench-scale, 1)); - transform-origin: center; - transition: transform 180ms ease; -} - -.workbench-definition, .workbench-review, .workbench-action-setup, .workbench-action-review, .workbench-generation, .workbench-complete { - overflow: hidden; - border: 1px solid rgba(29, 40, 32, 0.12); - border-radius: 18px; - background: rgba(252, 253, 250, 0.97); - box-shadow: 0 24px 64px rgba(31, 42, 34, 0.1); -} - -.workbench-definition { - padding: 28px; -} - -.workbench-definition--source { - text-align: center; -} - -.workbench-definition--source > h2 { - margin: 9px 0; - font: 500 27px/1.2 Georgia, "Songti SC", serif; -} - -.workbench-definition--source > p { - margin: 0 0 22px; - color: #737a73; - font-size: 10px; -} - -.demo-source-grid--workbench { - grid-template-columns: repeat(3, minmax(0, 1fr)); - text-align: left; -} - -.demo-source-grid--workbench .demo-source-card { - min-height: 186px; -} - -.workbench-definition > header, .workbench-review > header, .workbench-action-setup > header, .workbench-action-review > header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; -} - -.workbench-definition > header h2, .workbench-review > header h2, .workbench-action-setup > header h2, .workbench-action-review > header h2 { - margin: 7px 0; - font: 500 25px/1.2 Georgia, "Songti SC", serif; -} - -.workbench-definition__fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 13px; - margin-top: 22px; -} - -.workbench-definition__fields label:nth-child(n + 3) { - grid-column: 1 / -1; -} - -.workbench-definition__fields label, .inspector-form label { - display: grid; - gap: 6px; -} - -.workbench-definition__fields label > span, .inspector-form label > span { - color: #697069; - font-size: 8px; - font-weight: 700; -} - -.workbench-definition__fields input, .workbench-definition__fields textarea, .inspector-form select, .inspector-form textarea, .inspector-review textarea { - width: 100%; - border: 1px solid #d9ddd7; - border-radius: 9px; - outline: none; - color: #202720; - background: #f2f4f0; - font: inherit; - font-size: 9px; -} - -.workbench-definition__fields input { - height: 38px; - padding: 0 11px; -} - -.workbench-definition__fields textarea, .inspector-form textarea, .inspector-review textarea { - padding: 10px 11px; - resize: none; - line-height: 1.5; -} - -.workbench-definition > footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - margin-top: 20px; - padding-top: 18px; - border-top: 1px solid #e4e7e2; -} - -.workbench-definition > footer span { - display: grid; - gap: 3px; -} - -.workbench-definition > footer b { - font-size: 9px; -} - -.workbench-definition > footer small { - color: #8a9089; - font-size: 7px; -} - -.workbench-generation { - padding: 26px; -} - -.generation-status { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 13px; - align-items: center; -} - -.generation-status__pulse { - display: block; - width: 9px; - height: 9px; - border-radius: 50%; - background: #587762; - box-shadow: 0 0 0 0 rgba(88, 119, 98, 0.34); - animation: workbench-pulse 1.25s ease-out infinite; -} - -.generation-status h2 { - margin: 5px 0 3px; - font: 500 23px/1.2 Georgia, "Songti SC", serif; -} - -.generation-status p { - margin: 0; - color: #7a817a; - font-size: 9px; -} - -.generation-status > strong { - color: #486151; - font: 500 24px Georgia, serif; -} - -.generation-master-field { - position: relative; - display: grid; - height: 340px; - margin-top: 22px; - place-items: center; - overflow: hidden; - border-radius: 14px; - background: #e9ece7; -} - -.generation-master-field img { - position: relative; - z-index: 2; - width: 270px; - height: 270px; - object-fit: contain; - image-rendering: pixelated; - filter: blur(16px) saturate(0.7); - animation: workbench-image-resolve 2.4s ease-in-out infinite alternate; -} - -.generation-grid { - position: absolute; - inset: 0; - background-image: linear-gradient(rgba(48, 67, 54, 0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(48, 67, 54, 0.07) 1px, transparent 1px); - background-size: 22px 22px; -} - -.generation-particles { - position: absolute; - z-index: 3; - inset: 0; -} - -.generation-particles i { - position: absolute; - width: 3px; - height: 3px; - border-radius: 50%; - background: #66866f; - animation: workbench-particle 1.8s ease-in-out infinite alternate; -} - -.generation-particles i:nth-child(3n + 1) { - top: 24%; - left: 22%; - animation-delay: -0.3s; -} - -.generation-particles i:nth-child(3n + 2) { - top: 62%; - left: 73%; - animation-delay: -0.8s; -} - -.generation-particles i:nth-child(3n) { - top: 76%; - left: 35%; - animation-delay: -1.2s; -} - -.generation-progress { - height: 3px; - margin-top: 18px; - overflow: hidden; - border-radius: 99px; - background: #e0e4df; -} - -.generation-progress i { - display: block; - width: 38%; - height: 100%; - border-radius: inherit; - background: #587762; - animation: workbench-progress 1.5s ease-in-out infinite; -} - -.generation-frame-field { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 9px; - margin-top: 22px; -} - -.generation-frame-field > span { - position: relative; - display: grid; - aspect-ratio: 1; - place-items: center; - overflow: hidden; - border: 1px solid #dde1dc; - border-radius: 11px; - background: #e9ece7; - opacity: 0.34; -} - -.generation-frame-field > span.is-revealed { - opacity: 1; - animation: workbench-frame-reveal 520ms ease both; -} - -.generation-frame-field img { - width: 100%; - height: 100%; - object-fit: contain; - image-rendering: pixelated; -} - -.generation-frame-field small { - position: absolute; - right: 6px; - bottom: 5px; - color: #768078; - font: 700 7px monospace; -} - -.workbench-review, .workbench-action-setup, .workbench-action-review { - padding: 28px; -} - -.workbench-review > header p, .workbench-action-setup > header p, .workbench-action-review > header p { - max-width: 540px; - margin: 0; - color: #777e77; - font-size: 9px; - line-height: 1.6; -} - -.review-required { - padding: 7px 10px; - border-radius: 999px; - color: #7b5b2e; - background: #f4ead8; - font-size: 8px; - font-weight: 750; - white-space: nowrap; -} - -.master-candidate { - display: grid; - grid-template-columns: minmax(280px, 0.9fr) 1fr; - gap: 22px; - margin-top: 22px; -} - -.master-candidate__image { - position: relative; - display: grid; - min-height: 310px; - place-items: center; - overflow: hidden; - border-radius: 14px; - background: #e9ece7; -} - -.master-candidate__image img { - position: relative; - z-index: 2; - width: 260px; - height: 260px; - object-fit: contain; - image-rendering: pixelated; - animation: workbench-candidate-arrive 600ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -.master-candidate__image small { - position: absolute; - z-index: 3; - right: 12px; - bottom: 10px; - color: #778078; - font: 700 7px monospace; -} - -.master-candidate__facts { - padding: 18px 0; -} - -.master-candidate__facts h3 { - margin: 13px 0 8px; - font: 500 22px Georgia, "Songti SC", serif; -} - -.master-candidate__facts p { - min-height: 64px; - margin: 0; - color: #757c75; - font-size: 9px; - line-height: 1.65; -} - -.master-candidate__facts dl { - display: grid; - gap: 8px; - margin-top: 18px; -} - -.master-candidate__facts dl div { - display: flex; - justify-content: space-between; - padding: 9px 0; - border-bottom: 1px solid #e4e7e2; - font-size: 8px; -} - -.master-candidate__facts dt { - color: #8b918a; -} - -.master-candidate__facts dd { - margin: 0; - font-weight: 700; -} - -.workbench-review > footer { - display: flex; - justify-content: flex-end; - gap: 9px; - margin-top: 20px; - padding-top: 18px; - border-top: 1px solid #e4e7e2; -} - -.workbench-action-setup > header img { - width: 62px; - height: 62px; - object-fit: contain; - border-radius: 10px; - background: #e9ece7; - image-rendering: pixelated; -} - -.action-choice-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; - margin-top: 24px; -} - -.action-choice-grid label { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 10px; - align-items: center; - padding: 16px; - border: 1px solid #d9ddd7; - border-radius: 12px; - background: #f7f8f5; - cursor: pointer; -} - -.action-choice-grid input { - accent-color: #34543d; -} - -.action-choice-grid span { - display: grid; - gap: 4px; -} - -.action-choice-grid b { - font-size: 10px; -} - -.action-choice-grid small { - color: #858b84; - font-size: 8px; -} - -.action-choice-grid i { - color: #486451; - font-style: normal; -} - -.action-cost { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-top: 18px; -} - -.action-cost > span { - display: grid; - gap: 5px; - padding: 13px; - border-radius: 10px; - background: #eef1ec; -} - -.action-cost small { - color: #838982; - font-size: 7px; -} - -.action-cost b { - font-size: 9px; -} - -.action-review-stage { - display: grid; - grid-template-columns: minmax(300px, 0.9fr) 1fr; - gap: 24px; - align-items: center; - margin-top: 24px; -} - -.action-loop { - position: relative; - display: grid; - height: 300px; - place-items: center; - overflow: hidden; - border-radius: 14px; - background: radial-gradient(circle, var(--workspace-white), #e5e9e4); -} - -.action-loop img { - position: absolute; - width: 260px; - height: 260px; - object-fit: contain; - image-rendering: pixelated; - opacity: 0; - animation: workbench-frame-cycle 1s steps(1) infinite; -} - -.action-loop img:nth-child(2) { - animation-delay: -0.875s; -} - -.action-loop img:nth-child(3) { - animation-delay: -0.75s; -} - -.action-loop img:nth-child(4) { - animation-delay: -0.625s; -} - -.action-loop img:nth-child(5) { - animation-delay: -0.5s; -} - -.action-loop img:nth-child(6) { - animation-delay: -0.375s; -} - -.action-loop img:nth-child(7) { - animation-delay: -0.25s; -} - -.action-loop img:nth-child(8) { - animation-delay: -0.125s; -} - -.action-review-meta h3 { - margin: 15px 0 8px; - font: 500 20px Georgia, "Songti SC", serif; -} - -.action-review-meta p { - color: #777e77; - font-size: 9px; - line-height: 1.65; -} - -.workbench-complete { - padding: 48px; - text-align: center; -} - -.workbench-complete__mark { - display: grid; - width: 54px; - height: 54px; - margin: 0 auto 18px; - place-items: center; - border-radius: 50%; - color: #fff; - background: #42614a; - box-shadow: 0 0 0 9px rgba(66, 97, 74, 0.09); - font-size: 20px; -} - -.workbench-complete h2 { - margin: 12px 0 10px; - font: 500 28px Georgia, "Songti SC", serif; -} - -.workbench-complete > p { - margin: 0 auto; - max-width: 520px; - color: #767d76; - font-size: 10px; - line-height: 1.7; -} - -.workbench-complete__actions { - display: flex; - justify-content: center; - gap: 9px; - margin-top: 24px; -} - -.inspector-form, .inspector-review, .inspector-facts { - display: grid; - gap: 13px; - margin: 0; - padding: 18px; -} - -.inspector-form select { - height: 36px; - padding: 0 9px; -} - -.inspector-form .button { - margin-top: 5px; -} - -.inspector-facts div { - display: flex; - justify-content: space-between; - gap: 12px; - padding: 9px 0; - border-bottom: 1px solid #e1e4df; - font-size: 8px; -} - -.inspector-facts dt { - color: #8b908a; -} - -.inspector-facts dd { - margin: 0; - text-align: right; - font-weight: 700; -} - -.inspector-review > span { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 8px; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid #e1e4df; -} - -.inspector-review > span i { - color: #52705b; - font-style: normal; -} - -.inspector-review > span b { - font-size: 8px; -} - -.inspector-review > span small { - color: #66806b; - font-size: 7px; -} - -.inspector-review > div { - display: grid; - gap: 7px; -} - -.inspector-process { - display: grid; - gap: 8px; - margin: 0 18px; - padding: 14px; - border-radius: 11px; - background: #e8eee7; -} - -.inspector-process > span { - display: flex; - gap: 8px; - align-items: center; -} - -.inspector-process .generation-status__pulse { - width: 7px; - height: 7px; -} - -.inspector-process b { - font-size: 8px; -} - -.inspector-process p { - margin: 0; - color: #5f6c62; - font-size: 8px; - line-height: 1.5; -} - -.inspector-process small { - color: #7e887f; - font-size: 7px; - line-height: 1.5; -} - -.workbench-dock { - position: relative; - z-index: 4; - display: grid; - grid-template-columns: minmax(0, 1fr) 250px; - grid-template-rows: 34px minmax(0, 1fr); - border-top: 1px solid rgba(28, 37, 30, 0.12); - background: rgba(248, 249, 246, 0.98); -} - -.workbench-dock > header { - grid-column: 1; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 16px; - border-right: 1px solid #e0e3de; - border-bottom: 1px solid #e0e3de; -} - -.workbench-dock > header nav { - display: flex; - gap: 14px; -} - -.workbench-dock > header button { - padding: 0; - color: #929791; - background: transparent; - font-size: 8px; - font-weight: 700; -} - -.workbench-dock > header button.is-active { - color: #2d4935; -} - -.workbench-dock > header > span { - color: #8a9089; - font: 700 7px monospace; -} - -.workbench-filmstrip { - display: grid; - grid-column: 1; - grid-template-columns: repeat(8, minmax(58px, 1fr)); - gap: 6px; - min-width: 0; - padding: 8px 12px; - overflow: auto; -} - -.workbench-filmstrip > span { - position: relative; - display: grid; - min-width: 58px; - place-items: center; - overflow: hidden; - border: 1px solid #dde0dc; - border-radius: 8px; - background: #ecefeb; -} - -.workbench-filmstrip img { - width: 100%; - height: 78px; - object-fit: contain; - image-rendering: pixelated; -} - -.workbench-filmstrip i { - color: #a5aaa4; - font-style: normal; -} - -.workbench-filmstrip small { - position: absolute; - right: 4px; - bottom: 3px; - color: #808780; - font: 700 6px monospace; -} - -.workbench-filmstrip > span.is-ready { - animation: workbench-frame-reveal 380ms ease both; -} - -.workbench-filmstrip > span:nth-child(2) { - animation-delay: 40ms; -} - -.workbench-filmstrip > span:nth-child(3) { - animation-delay: 80ms; -} - -.workbench-filmstrip > span:nth-child(4) { - animation-delay: 120ms; -} - -.workbench-filmstrip > span:nth-child(5) { - animation-delay: 160ms; -} - -.workbench-filmstrip > span:nth-child(6) { - animation-delay: 200ms; -} - -.workbench-filmstrip > span:nth-child(7) { - animation-delay: 240ms; -} - -.workbench-filmstrip > span:nth-child(8) { - animation-delay: 280ms; -} - -.workbench-job { - grid-column: 2; - grid-row: 1 / -1; - display: grid; - grid-template-columns: auto 1fr auto; - gap: 9px; - align-items: center; - padding: 14px; - border-left: 1px solid #e0e3de; -} - -.workbench-job > i { - width: 8px; - height: 8px; - border-radius: 50%; - background: #b7bbb6; -} - -.workbench-job > i.is-running { - background: #587762; - animation: workbench-pulse 1.25s ease-out infinite; -} - -.workbench-job span { - display: grid; - gap: 4px; -} - -.workbench-job b { - font-size: 8px; -} - -.workbench-job small { - color: #90958f; - font-size: 7px; - line-height: 1.35; -} - -.workbench-job strong { - color: #58705e; - font: 500 14px Georgia, serif; -} - -@keyframes workbench-pulse { - 70%, 100% { - box-shadow: 0 0 0 9px rgba(88, 119, 98, 0); - } - -} - -@keyframes workbench-image-resolve { - from { - opacity: 0.38; - filter: blur(16px) saturate(0.65); - transform: scale(0.94); - } - - to { - opacity: 0.9; - filter: blur(3px) saturate(0.92); - transform: scale(1); - } - -} - -@keyframes workbench-particle { - from { - opacity: 0.15; - transform: translate(-35px, 20px) scale(0.5); - } - - to { - opacity: 0.85; - transform: translate(48px, -30px) scale(1.2); - } - -} - -@keyframes workbench-progress { - 0% { - width: 16%; - transform: translateX(-100%); - } - - 55% { - width: 54%; - } - - 100% { - width: 28%; - transform: translateX(360%); - } - -} - -@keyframes workbench-frame-reveal { - from { - opacity: 0; - filter: blur(12px); - transform: translateY(8px) scale(0.94); - } - - to { - opacity: 1; - filter: none; - transform: none; - } - -} - -@keyframes workbench-candidate-arrive { - from { - opacity: 0; - filter: blur(12px); - transform: translateY(14px) scale(0.94); - } - - to { - opacity: 1; - filter: none; - transform: none; - } - -} - -@keyframes workbench-frame-cycle { - 0%, 12.49% { - opacity: 1; - } - - 12.5%, 100% { - opacity: 0; - } - -} - -@media (max-width: 1180px) { - .creation-workbench__body { - grid-template-columns: 190px minmax(500px, 1fr) 240px; - } - - .workbench-flow small { - display: none; - } - -} - -@media (max-width: 860px) { - .creation-workbench { - height: auto; - min-height: calc(100svh - 68px); - grid-template-rows: auto auto auto; - } - - .workbench-flow { - grid-template-columns: repeat(5, 1fr); - min-height: 58px; - padding: 0 8px; - } - - .workbench-flow span { - display: none; - } - - .creation-workbench__body { - grid-template-columns: 1fr; - } - - .workbench-assets { - display: none; - } - - .workbench-inspector { - border-top: 1px solid #dfe2dd; - border-left: 0; - } - - .workbench-canvas { - min-height: 680px; - } - - .workbench-dock { - grid-template-columns: 1fr; - min-height: 160px; - } - - .workbench-job { - display: none; - } - -} - -@media (prefers-reduced-motion: reduce) { - .product-brand, .product-nav, .studio-bar__left, .studio-bar__right { - view-transition-name: none; - } - - .generation-status__pulse, .generation-particles i, .generation-master-field img, .generation-progress i, .action-loop img, .workbench-filmstrip > span, .workbench-job > i { - animation: none !important; - } - - .generation-master-field img { - opacity: 0.9; - filter: blur(2px); - } - - .action-loop img:first-child { - opacity: 1; - } - -} - -/* Node workspace readability and progressive reveal */ -.production-canvas-workspace { - height: calc(100svh - 68px); - min-height: 660px; - overflow: hidden; -} - -.node-graph-workspace { - height: 100%; -} - -.node-surface { - width: 2820px; - height: 1480px; -} - -.node-surface > .node-wires { - width: 2820px; - height: 1480px; -} - -.graph-node { - width: 368px; - animation: node-card-enter 280ms cubic-bezier(.2,.8,.2,1) both; -} - -.graph-node > header { - min-height: 62px; - padding: 12px 18px; -} - -.graph-node > header small { - font-size: 8px; -} - -.graph-node > header h2 { - font-size: 14px; -} - -.graph-node__body { - gap: 17px; - padding: 21px; -} - -.graph-node__body > p { - font-size: 11px; - line-height: 1.65; -} - -.graph-port { - top: 38px; - width: 22px; - height: 22px; - border-width: 4px; -} - -.graph-port--input { - left: -11px; -} - -.graph-port--output { - right: -11px; -} - -.graph-port.is-connectable { - background: #3f8150; - box-shadow: 0 0 0 7px rgba(63, 129, 80, .18), 0 0 0 1px #315f3c; - animation: node-port-ready 850ms ease-in-out infinite alternate; -} - -.node-status span, .node-status b { - font-size: 10px; -} - -.node-action { - min-height: 42px; - padding: 9px 12px; - font-size: 11px; -} - -.node-source-list button { - padding: 11px 12px; -} - -.node-source-list span { - font-size: 11px; -} - -.node-source-list small { - font-size: 8px; -} - -.node-brief-form { - display: grid; - gap: 14px; -} - -.node-brief-form label { - display: grid; - gap: 7px; -} - -.node-brief-form label > span { - color: #626b64; - font-size: 9px; - font-weight: 750; -} - -.node-brief-form input:not([type="file"]), .node-brief-form textarea, .node-brief-form select { - width: 100%; - border: 1px solid #d5dbd5; - border-radius: 7px; - outline: none; - color: #242c26; - background: #eef1ed; - font: inherit; - font-size: 10px; -} - -.node-brief-form input:not([type="file"]), .node-brief-form select { - height: 40px; - padding: 0 11px; -} - -.node-brief-form textarea { - min-height: 76px; - padding: 10px 11px; - resize: vertical; - line-height: 1.6; -} - -.node-brief-form input:focus, .node-brief-form textarea:focus, .node-brief-form select:focus { - border-color: #66836e; - box-shadow: 0 0 0 3px rgba(82, 119, 92, .1); -} - -.node-brief-form input[type="file"] { - width: 100%; - color: #6c746d; - font-size: 8px; -} - -.node-animation-form { - grid-template-columns: 1fr; -} - -.custom-action-placeholder > small { - color: #788078; - font-size: 8px; - line-height: 1.5; -} - -.master-choice-grid span { - font-size: 8px; -} - -.confirmed-master figcaption, .keyframe-preview figcaption { - font-size: 8px; -} - -.node-frame-strip small { - font-size: 7px; -} - -.node-graph-toolbar { - min-height: 56px; -} - -.node-graph-toolbar b { - font-size: 13px; -} - -.node-graph-toolbar small { - font-size: 9px; -} - -.node-graph-toolbar button { - height: 36px; - padding: 0 12px; - font-size: 10px; -} - -.node-canvas-hint { - max-width: 380px; - gap: 4px; - padding: 12px 15px; -} - -.node-canvas-hint b { - font-size: 11px; -} - -.node-canvas-hint span { - font-size: 9px; - line-height: 1.5; -} - -.node-zoom { - height: 44px; -} - -.node-zoom button { - width: 36px; - height: 36px; -} - -.node-zoom output { - width: 52px; - font-size: 9px; -} - -.node-wire.is-connected { - stroke-width: 4; -} - -.node-wire.is-connected.is-new { - stroke-dasharray: 1; - stroke-dashoffset: 1; - animation: node-wire-commit 720ms cubic-bezier(.2,.75,.2,1) forwards; -} - -.node-wire.is-suggested { - stroke: rgba(72, 101, 80, .42); - stroke-width: 3; - stroke-dasharray: 9 8; - animation: node-wire-guide 1.1s linear infinite; -} - -.graph-node.is-waiting-connection .graph-port--input { - background: #4f805b; - box-shadow: 0 0 0 6px rgba(79, 128, 91, .13), 0 0 0 1px #3d6547; -} - -.graph-node.is-waiting-connection { - cursor: pointer; -} - -.graph-node__connect-surface { - display: none; -} - -.graph-node.is-waiting-connection .graph-node__connect-surface { - position: absolute; - z-index: 8; - inset: 62px 0 0; - display: flex; - align-items: flex-end; - justify-content: flex-end; - padding: 0 16px 14px; - border-radius: 0 0 12px 12px; - color: #46614d; - background: linear-gradient(180deg, rgba(246, 249, 246, .04), rgba(226, 237, 228, .2)); - cursor: pointer; -} - -.graph-node__connect-surface span { - padding: 6px 9px; - border: 1px solid rgba(72, 104, 81, .2); - border-radius: 999px; - background: rgba(246, 249, 246, .86); - box-shadow: 0 4px 14px rgba(36, 52, 40, .08); - font-size: 9px; - font-weight: 750; - backdrop-filter: blur(8px); -} - -.graph-node.is-waiting-connection .graph-node__connect-surface:hover { - background: linear-gradient(180deg, rgba(232, 241, 234, .08), rgba(209, 226, 213, .32)); -} - -.graph-node.is-connection-committed { - animation: node-card-commit 820ms cubic-bezier(.2,.8,.2,1) both; -} - -@keyframes node-card-enter { - from { - opacity: 0; - filter: blur(7px); - transform: translateY(10px) scale(.97); - } - - to { - opacity: 1; - filter: none; - transform: none; - } - -} - -@keyframes node-port-ready { - from { - transform: scale(1); - } - - to { - transform: scale(1.2); - } - -} - -@keyframes node-wire-guide { - to { - stroke-dashoffset: -17; - } - -} - -@keyframes node-wire-commit { - 0% { - opacity: .28; - stroke-dashoffset: 1; - } - - 72% { - opacity: 1; - filter: drop-shadow(0 0 6px rgba(71, 116, 82, .5)); - } - - 100% { - opacity: 1; - stroke-dashoffset: 0; - filter: none; - } - -} - -@keyframes node-card-commit { - 0%, 100% { - box-shadow: 0 14px 36px rgba(30, 39, 32, .13); - } - - 44% { - box-shadow: 0 0 0 4px rgba(71, 116, 82, .16), 0 20px 48px rgba(30, 50, 36, .2); - } - -} - -/* Studio entry modes and local natural-language creation. These surfaces reuse - the restrained monochrome production language instead of introducing a - separate chat-product visual system. */ -body:has(.studio-mode-gateway), body:has(.natural-creation) { - overflow: hidden; -} - -.production-canvas-workspace:has(> .studio-mode-gateway), .production-canvas-workspace:has(> .natural-creation) { - height: 100svh; - min-height: 660px; - padding: 0; - overflow: auto; - color: #171817; - background: #f4f3ef; -} - -.workflow-app:has(.studio-mode-gateway), .workflow-app:has(.natural-creation) { - min-height: 100svh; - background: #f4f3ef; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar, .workflow-app:has(.natural-creation) .studio-bar { - color: #171817; - background: rgba(244, 243, 239, .92); - box-shadow: inset 0 -1px rgba(20, 20, 20, .1); - -webkit-backdrop-filter: blur(24px) saturate(110%); - backdrop-filter: blur(24px) saturate(110%); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__left, .workflow-app:has(.natural-creation) .studio-bar__left, .workflow-app:has(.studio-mode-gateway) .studio-bar__right, .workflow-app:has(.natural-creation) .studio-bar__right { - color: #171817; - background: transparent; - box-shadow: none; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__left .product-brand__mark, .workflow-app:has(.natural-creation) .studio-bar__left .product-brand__mark { - background: #171817; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__brand, .workflow-app:has(.natural-creation) .studio-bar__brand { - border-right-color: rgba(20, 20, 20, .12); - color: #171817; -} - -.studio-mode-gateway { - position: relative; - display: grid; - width: 100%; - min-height: 100%; - grid-template-rows: auto 1fr auto; - gap: 34px; - padding: 112px clamp(30px, 6vw, 104px) 42px; - overflow: hidden; - background: radial-gradient(circle at 78% 18%, rgba(88, 111, 94, .08), transparent 28%), linear-gradient(150deg, #f8f7f3, #ecece7 74%); - animation: studio-mode-enter 520ms cubic-bezier(.22, 1, .36, 1) both; -} - -.studio-mode-gateway::before { - position: absolute; - inset: 0; - content: ""; - opacity: .28; - background-image: radial-gradient(circle, rgba(27, 31, 28, .24) 1px, transparent 1.2px); - background-size: 24px 24px; - -webkit-mask-image: linear-gradient(120deg, transparent 8%, #000 48%, transparent 96%); - mask-image: linear-gradient(120deg, transparent 8%, #000 48%, transparent 96%); - pointer-events: none; -} - -.studio-mode-gateway > * { - position: relative; - z-index: 1; -} - -.studio-mode-gateway__header { - display: grid; - max-width: 720px; - gap: 11px; -} - -.studio-mode-gateway__header .overline { - color: #6e756f; - font: 700 8px/1 monospace; - letter-spacing: .18em; -} - -.studio-mode-gateway__header h1 { - margin: 0; - font: 500 clamp(34px, 4.2vw, 62px)/1.04 Georgia, "Songti SC", serif; - letter-spacing: -.035em; -} - -.studio-mode-gateway__header p { - max-width: 610px; - margin: 0; - color: #666b67; - font-size: 12px; - line-height: 1.75; -} - -.studio-mode-gateway__choices { - display: grid; - min-height: 360px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; -} - -.studio-mode-card { - position: relative; - display: grid; - min-height: 100%; - grid-template-columns: auto 1fr; - grid-template-rows: auto 1fr auto; - gap: 24px 30px; - padding: clamp(26px, 3.5vw, 52px); - overflow: hidden; - border: 1px solid rgba(20, 20, 20, .12); - border-radius: 24px; - color: #1a1b1a; - background: rgba(250, 250, 247, .78); - box-shadow: 0 26px 64px rgba(28, 35, 30, .09), inset 0 1px rgba(255, 255, 255, .72); - text-align: left; - transition: border-color 240ms ease, box-shadow 240ms ease, transform 240ms cubic-bezier(.22, 1, .36, 1); -} - -.studio-mode-card::after { - position: absolute; - inset: auto -12% -44% 35%; - height: 70%; - border-radius: 50%; - content: ""; - background: radial-gradient(circle, rgba(75, 103, 82, .13), transparent 68%); - transition: transform 420ms ease; - pointer-events: none; -} - -.studio-mode-card:hover, .studio-mode-card:focus-visible { - border-color: rgba(40, 69, 48, .36); - box-shadow: 0 34px 80px rgba(25, 36, 28, .15), inset 0 1px #fff; - transform: translateY(-6px); -} - -.studio-mode-card:hover::after { - transform: translate(-6%, -8%) scale(1.12); -} - -.studio-mode-card--natural { - color: #f2f2ed; - border-color: rgba(255, 255, 255, .13); - background: linear-gradient(145deg, #111311, #242824); - box-shadow: 0 30px 76px rgba(14, 18, 15, .23), inset 0 1px rgba(255, 255, 255, .08); -} - -.studio-mode-card--natural:hover, .studio-mode-card--natural:focus-visible { - border-color: rgba(255, 255, 255, .32); - box-shadow: 0 38px 88px rgba(12, 18, 14, .3), inset 0 1px rgba(255, 255, 255, .12); -} - -.studio-mode-card__index { - align-self: start; - color: #858985; - font: 700 9px/1 monospace; - letter-spacing: .14em; -} - -.studio-mode-card--natural .studio-mode-card__index { - color: #7f8b82; -} - -.studio-mode-card__copy { - display: grid; - align-content: end; - gap: 10px; - grid-column: 1 / -1; -} - -.studio-mode-card__copy small { - color: #7b827c; - font: 700 8px/1 monospace; - letter-spacing: .14em; -} - -.studio-mode-card__copy b { - font: 500 clamp(25px, 2.6vw, 39px)/1.1 Georgia, "Songti SC", serif; -} - -.studio-mode-card__copy p { - max-width: 470px; - margin: 0; - color: #6b716c; - font-size: 11px; - line-height: 1.72; -} - -.studio-mode-card--natural .studio-mode-card__copy p { - color: #999f9a; -} - -.studio-mode-card__action { - grid-column: 1 / -1; - padding-top: 20px; - border-top: 1px solid rgba(20, 20, 20, .1); - font-size: 10px; - font-weight: 750; -} - -.studio-mode-card--natural .studio-mode-card__action { - border-top-color: rgba(255, 255, 255, .12); -} - -.studio-mode-card__glyph { - position: relative; - display: block; - width: 116px; - height: 78px; - justify-self: end; - grid-column: 2; -} - -.studio-mode-card__glyph i { - position: absolute; - display: block; - border: 1px solid #747b75; - background: rgba(255, 255, 255, .42); -} - -.studio-mode-card__glyph--workflow i { - width: 26px; - height: 18px; - border-radius: 4px; -} - -.studio-mode-card__glyph--workflow i::after { - position: absolute; - top: 50%; - left: 100%; - width: 17px; - height: 1px; - content: ""; - background: #969c97; -} - -.studio-mode-card__glyph--workflow .glyph-part-1 { - top: 29px; - left: 0; -} - -.studio-mode-card__glyph--workflow .glyph-part-2 { - top: 29px; - left: 43px; -} - -.studio-mode-card__glyph--workflow .glyph-part-3 { - top: 4px; - right: 0; -} - -.studio-mode-card__glyph--workflow .glyph-part-4 { - top: 29px; - right: 0; -} - -.studio-mode-card__glyph--workflow .glyph-part-5 { - right: 0; - bottom: 4px; -} - -.studio-mode-card__glyph--workflow .glyph-part-2::after { - width: 22px; - transform: rotate(-34deg); - transform-origin: left; -} - -.studio-mode-card__glyph--workflow .glyph-part-4::after, .studio-mode-card__glyph--workflow .glyph-part-5::after { - display: none; -} - -.studio-mode-card__glyph--natural { - display: grid; - grid-template-columns: repeat(7, 7px); - place-content: center; - gap: 6px; -} - -.studio-mode-card__glyph--natural i { - position: static; - width: 7px; - height: 7px; - border: 0; - border-radius: 2px; - background: #8b978d; - animation: natural-glyph-signal 2.8s ease-in-out infinite; -} - -.studio-mode-card__glyph--natural i:nth-child(2) { - animation-delay: -.4s; -} - -.studio-mode-card__glyph--natural i:nth-child(3) { - animation-delay: -.8s; -} - -.studio-mode-card__glyph--natural i:nth-child(4) { - animation-delay: -1.2s; -} - -.studio-mode-card__glyph--natural i:nth-child(5) { - animation-delay: -1.6s; -} - -.studio-mode-card__glyph--natural i:nth-child(6) { - animation-delay: -2s; -} - -.studio-mode-card__glyph--natural i:nth-child(7) { - animation-delay: -2.4s; -} - -.studio-mode-gateway__note { - display: flex; - align-items: center; - gap: 12px; -} - -.studio-mode-gateway__note > i { - width: 7px; - height: 7px; - border-radius: 2px; - background: #4d6b55; - box-shadow: 0 0 0 5px rgba(65, 96, 73, .1); -} - -.studio-mode-gateway__note > span { - display: grid; - gap: 3px; -} - -.studio-mode-gateway__note b { - font-size: 9px; -} - -.studio-mode-gateway__note small { - color: #767c77; - font-size: 8px; -} - -.natural-creation { - width: 100%; - min-height: 100%; - animation: studio-mode-enter 460ms cubic-bezier(.22, 1, .36, 1) both; -} - -.natural-creation[data-agent-settled] { - animation: none; -} -.natural-creation[data-agent-settled] .is-arrived { - animation: none; -} - -.natural-creation--input { - display: grid; - grid-template-columns: minmax(360px, 36vw) minmax(560px, 1fr); - background: #f5f4f0; -} - -.natural-creation__aside { - position: relative; - display: grid; - align-content: center; - gap: 18px; - padding: 112px clamp(38px, 5vw, 82px) 48px; - overflow: hidden; - color: #efefe9; - background: linear-gradient(145deg, #0d0f0d, #222622); -} - -.natural-creation__aside::after { - position: absolute; - right: -28%; - bottom: -18%; - width: 74%; - aspect-ratio: 1; - border: 1px solid rgba(255, 255, 255, .08); - border-radius: 50%; - content: ""; - box-shadow: 0 0 0 42px rgba(255, 255, 255, .025), 0 0 0 84px rgba(255, 255, 255, .018); -} - -.natural-creation__aside > * { - position: relative; - z-index: 1; -} - -.natural-creation__aside .overline { - color: #879087; - font: 700 8px monospace; - letter-spacing: .16em; -} - -.natural-creation__aside h1 { - max-width: 440px; - margin: 0; - font: 500 clamp(33px, 4vw, 54px)/1.06 Georgia, "Songti SC", serif; -} - -.natural-creation__aside > p { - max-width: 430px; - margin: 0; - color: #9ba09b; - font-size: 11px; - line-height: 1.8; -} - -.natural-stage-map { - display: grid; - gap: 0; - margin: 22px 0 0; - padding: 0; - list-style: none; -} - -.natural-stage-map li { - position: relative; - display: grid; - min-height: 54px; - grid-template-columns: 30px 1fr; - gap: 13px; - align-items: center; - border-top: 1px solid rgba(255, 255, 255, .09); -} - -.natural-stage-map li:last-child { - border-bottom: 1px solid rgba(255, 255, 255, .09); -} - -.natural-stage-map li > span:first-child { - color: #667268; - font: 700 7px monospace; -} - -.natural-stage-map li > span:last-child { - display: grid; - gap: 4px; -} - -.natural-stage-map b { - color: #d9dcd8; - font-size: 9px; -} - -.natural-stage-map small { - color: #777f78; - font-size: 7px; -} - -.natural-command-form { - display: grid; - max-width: 820px; - width: 100%; - grid-template-rows: auto auto auto auto 1fr auto; - gap: 20px; - align-content: center; - justify-self: center; - padding: 104px clamp(44px, 7vw, 112px) 46px; -} - -.natural-command-form > header { - display: grid; - gap: 9px; - padding-bottom: 19px; - border-bottom: 1px solid rgba(20, 20, 20, .1); -} - -.natural-command-form > header .overline { - color: #747b75; - font: 700 7px monospace; - letter-spacing: .15em; -} - -.natural-command-form > header h2 { - margin: 0; - font: 500 clamp(27px, 3vw, 40px)/1.12 Georgia, "Songti SC", serif; -} - -.natural-command-form > header p { - margin: 0; - color: #777c78; - font-size: 10px; - line-height: 1.65; -} - -.natural-command-form > label { - display: grid; - gap: 9px; -} - -.natural-command-form > label > span { - display: flex; - justify-content: space-between; -} - -.natural-command-form > label b { - font-size: 10px; -} - -.natural-command-form > label small { - color: #858a86; - font: 700 7px monospace; -} - -.natural-command-form textarea { - min-height: 176px; - padding: 18px; - border: 1px solid rgba(22, 27, 23, .14); - border-radius: 15px; - outline: none; - color: #242624; - background: #ecece7; - box-shadow: inset 0 1px rgba(255, 255, 255, .7); - font: 11px/1.75 inherit; - resize: vertical; - transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; -} - -.natural-command-form textarea:focus { - border-color: rgba(38, 63, 45, .5); - background: #fafaf6; - box-shadow: 0 0 0 4px rgba(64, 91, 71, .09), inset 0 1px #fff; -} - -.natural-command-form__error { - margin: -10px 0 0; - color: #9d4038; - font-size: 8px; -} - -.natural-command-examples { - display: flex; - flex-wrap: wrap; - gap: 7px; - align-items: center; -} - -.natural-command-examples > span { - margin-right: 4px; - color: #777e78; - font-size: 8px; -} - -.natural-command-examples button { - padding: 7px 10px; - border: 1px solid rgba(20, 20, 20, .1); - border-radius: 999px; - color: #5d655f; - background: rgba(255, 255, 255, .52); - font-size: 8px; -} - -.natural-command-examples button:hover { - color: #202520; - border-color: rgba(40, 70, 48, .28); - background: #fff; -} - -.natural-command-preview { - display: grid; - grid-template-columns: auto 1fr; - gap: 5px 13px; - align-items: center; - padding: 13px 15px; - border: 1px solid rgba(47, 68, 53, .1); - border-radius: 11px; - background: rgba(228, 233, 227, .58); -} - -.natural-command-preview > span { - grid-row: 1 / 3; - padding: 7px 9px; - border-radius: 7px; - color: #e8eee9; - background: #314a38; - font: 700 7px monospace; -} - -.natural-command-preview b { - font-size: 9px; -} - -.natural-command-preview small { - color: #747b75; - font-size: 7px; -} - -.natural-command-form > footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-top: 18px; - border-top: 1px solid rgba(20, 20, 20, .1); -} - -.natural-command-form > footer button { - min-height: 46px; - padding: 0 17px; - font-size: 10px; -} - -.natural-command-form > footer .button--primary { - display: inline-flex; - gap: 20px; - align-items: center; -} - -.natural-command-form > footer .button--primary i { - font-style: normal; -} - -.natural-creation--progress { - display: grid; - grid-template-rows: auto 1fr; - padding: 94px clamp(26px, 4vw, 68px) 34px; - background: linear-gradient(150deg, #eeeee9, #f8f7f3); -} - -.natural-progress__header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 24px; - padding: 0 2px 24px; - border-bottom: 1px solid rgba(20, 20, 20, .1); -} - -.natural-progress__header > span { - display: grid; - gap: 5px; -} - -.natural-progress__header small { - color: #6d776f; - font: 700 7px monospace; - letter-spacing: .15em; -} - -.natural-progress__header h1 { - margin: 0; - font: 500 31px/1.1 Georgia, "Songti SC", serif; -} - -.natural-progress__header button { - padding: 9px 12px; - border: 1px solid rgba(20, 20, 20, .11); - border-radius: 8px; - color: #5f6761; - background: rgba(255, 255, 255, .54); - font-size: 8px; -} - -.natural-progress__workspace { - display: grid; - min-height: 0; - grid-template-columns: minmax(0, 1.3fr) minmax(340px, .7fr); - gap: 18px; - padding-top: 18px; -} - -.natural-progress__visual, .natural-progress__console { - min-height: 0; - border: 1px solid rgba(31, 43, 35, .1); - border-radius: 18px; - background: rgba(250, 250, 247, .78); - box-shadow: 0 20px 52px rgba(31, 42, 34, .08), inset 0 1px #fff; -} - -.natural-progress__visual { - display: grid; - grid-template-rows: minmax(300px, 1fr) auto; - gap: 16px; - padding: 22px; - overflow: hidden; -} - -.natural-progress__master { - position: relative; - display: grid; - min-height: 0; - place-items: center; - overflow: hidden; - border-radius: 13px; - background: radial-gradient(circle at center, #fff, #e6e9e4 72%); -} - -.natural-progress__master > img { - position: relative; - z-index: 2; - width: min(42%, 330px); - aspect-ratio: 1; - object-fit: contain; - image-rendering: pixelated; - animation: natural-image-resolve 2.2s ease both; -} - -.natural-progress__master > span { - position: absolute; - z-index: 3; - bottom: 18px; - left: 20px; - display: grid; - gap: 4px; -} - -.natural-progress__master > span small { - color: #7c857e; - font: 700 7px monospace; - letter-spacing: .14em; -} - -.natural-progress__master > span b { - font: 500 17px Georgia, serif; -} - -.natural-progress__resolve { - position: absolute; - z-index: 1; - display: grid; - width: 280px; - height: 280px; - grid-template-columns: repeat(10, 6px); - place-content: center; - gap: 15px; -} - -.natural-progress__resolve i { - width: 6px; - height: 6px; - border-radius: 2px; - background: #67806d; - animation: natural-dot-resolve 2.3s ease-in-out infinite; -} - -.natural-progress__resolve .resolve-ring-1 { - animation-delay: -.2s; -} - -.natural-progress__resolve .resolve-ring-2 { - animation-delay: -.4s; -} - -.natural-progress__resolve .resolve-ring-3 { - animation-delay: -.6s; -} - -.natural-progress__resolve .resolve-ring-4 { - animation-delay: -.8s; -} - -.natural-progress__filmstrip { - display: grid; - grid-template-columns: repeat(8, minmax(0, 1fr)); - gap: 6px; - opacity: .26; - filter: grayscale(1) blur(2px); - transition: opacity 520ms ease, filter 520ms ease; -} - -.natural-progress__filmstrip.is-visible { - opacity: 1; - filter: none; -} - -.natural-progress__filmstrip span { - position: relative; - display: grid; - overflow: hidden; - border: 1px solid rgba(40, 55, 45, .1); - border-radius: 7px; - background: #edf0eb; -} - -.natural-progress__filmstrip img { - width: 100%; - aspect-ratio: 1; - object-fit: contain; - image-rendering: pixelated; -} - -.natural-progress__filmstrip small { - position: absolute; - right: 5px; - bottom: 4px; - color: #687069; - font: 700 6px monospace; -} - -.natural-progress__filmstrip.is-visible span { - animation: natural-frame-enter 420ms ease both; -} - -.natural-progress__filmstrip.is-visible span:nth-child(2) { - animation-delay: 60ms; -} - -.natural-progress__filmstrip.is-visible span:nth-child(3) { - animation-delay: 120ms; -} - -.natural-progress__filmstrip.is-visible span:nth-child(4) { - animation-delay: 180ms; -} - -.natural-progress__filmstrip.is-visible span:nth-child(5) { - animation-delay: 240ms; -} - -.natural-progress__filmstrip.is-visible span:nth-child(6) { - animation-delay: 300ms; -} - -.natural-progress__filmstrip.is-visible span:nth-child(7) { - animation-delay: 360ms; -} - -.natural-progress__filmstrip.is-visible span:nth-child(8) { - animation-delay: 420ms; -} - -.natural-progress__console { - display: grid; - align-content: start; - gap: 13px; - padding: 25px; - overflow: auto; -} - -.natural-progress__console > .overline { - color: #68736b; - font: 700 7px monospace; - letter-spacing: .15em; -} - -.natural-progress__console > h2 { - margin: 0; - font: 500 25px/1.12 Georgia, "Songti SC", serif; -} - -.natural-progress__console > p { - margin: 0; - color: #737a74; - font-size: 9px; - line-height: 1.65; -} - -.natural-progress__bar { - display: grid; - gap: 8px; - margin: 6px 0; - padding: 14px; - border-radius: 10px; - background: #e8ece7; -} - -.natural-progress__bar > span { - display: flex; - align-items: baseline; - justify-content: space-between; -} - -.natural-progress__bar b { - color: #314a38; - font: 500 21px Georgia, serif; -} - -.natural-progress__bar small { - color: #7c857e; - font: 700 7px monospace; -} - -.natural-progress__bar progress { - width: 100%; - height: 5px; - overflow: hidden; - border: 0; - border-radius: 99px; - appearance: none; - background: rgba(43, 65, 50, .1); -} - -.natural-progress__bar progress::-webkit-progress-bar { - border-radius: 99px; - background: rgba(43, 65, 50, .1); -} - -.natural-progress__bar progress::-webkit-progress-value { - border-radius: 99px; - background: linear-gradient(90deg, #294833, #709178); - transition: width 520ms cubic-bezier(.22, 1, .36, 1); -} - -.natural-progress__bar progress::-moz-progress-bar { - border-radius: 99px; - background: linear-gradient(90deg, #294833, #709178); - transition: width 520ms cubic-bezier(.22, 1, .36, 1); -} - -.natural-progress__steps { - display: grid; - margin: 0; - padding: 0; - list-style: none; -} - -.natural-progress__steps li { - display: grid; - min-height: 48px; - grid-template-columns: 28px 1fr auto; - gap: 10px; - align-items: center; - border-top: 1px solid rgba(28, 37, 31, .08); - opacity: .48; -} - -.natural-progress__steps li:last-child { - border-bottom: 1px solid rgba(28, 37, 31, .08); -} - -.natural-progress__steps li > i { - display: grid; - width: 22px; - height: 22px; - place-items: center; - border: 1px solid rgba(45, 63, 51, .16); - border-radius: 6px; - font: 700 6px monospace; - font-style: normal; -} - -.natural-progress__steps li > span { - display: grid; - gap: 3px; -} - -.natural-progress__steps li b { - font-size: 8px; -} - -.natural-progress__steps li small { - color: #777e78; - font-size: 6px; -} - -.natural-progress__steps li em { - color: #7c837d; - font-size: 6px; - font-style: normal; -} - -.natural-progress__steps li.is-running, .natural-progress__steps li.is-completed { - opacity: 1; -} - -.natural-progress__steps li.is-running > i { - color: #f1f4f0; - border-color: #385a42; - background: #385a42; - box-shadow: 0 0 0 5px rgba(56, 90, 66, .1); - animation: natural-step-pulse 1.1s ease-in-out infinite alternate; -} - -.natural-progress__steps li.is-completed > i { - color: #31563b; - border-color: rgba(49, 86, 59, .22); - background: #e1eae2; -} - -.natural-progress__intent { - display: grid; - gap: 9px; - padding: 12px; - border-radius: 9px; - background: #f0f1ed; -} - -.natural-progress__intent > span { - color: #727a73; - font: 700 6px monospace; - letter-spacing: .12em; -} - -.natural-progress__intent dl { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - margin: 0; -} - -.natural-progress__intent dl div { - display: grid; - gap: 3px; -} - -.natural-progress__intent dt { - color: #8a908b; - font-size: 6px; -} - -.natural-progress__intent dd { - margin: 0; - font-size: 7px; - font-weight: 750; -} - -.natural-creation--result { - display: grid; - grid-template-rows: auto 1fr; - padding: 98px clamp(30px, 5vw, 82px) 36px; - background: #f4f3ef; -} - - -.natural-result__assets { - display: grid; - grid-template-columns: minmax(220px, .42fr) minmax(0, 1fr); - gap: 18px; - padding: 20px; - overflow: auto; -} - -.natural-result__master { - display: grid; - min-height: 0; - margin: 0; - place-items: center; - overflow: hidden; - border-radius: 13px; - background: radial-gradient(circle, #fff, #e7eae5); -} - -.natural-result__master img { - width: min(86%, 330px); - aspect-ratio: 1; - object-fit: contain; - image-rendering: pixelated; -} - -.natural-result__master figcaption { - display: grid; - width: 100%; - gap: 4px; - padding: 14px 17px; - border-top: 1px solid rgba(32, 42, 35, .08); -} - -.natural-result__master small { - color: #7e867f; - font: 700 7px monospace; - letter-spacing: .12em; -} - -.natural-result__master b { - font: 500 17px Georgia, serif; -} - -.natural-result__sequences { - display: grid; - align-content: center; - gap: 10px; -} - -.natural-result__sequences > header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.natural-result__sequences > header span { - display: grid; - gap: 3px; -} - -.natural-result__sequences > header small { - color: #7c847d; - font: 700 6px monospace; - letter-spacing: .12em; -} - -.natural-result__sequences > header b { - font-size: 8px; -} - -.natural-result__sequences > header i { - color: #7b827c; - font: 700 6px monospace; - font-style: normal; -} - -.natural-result__sequences .node-frame-strip { - margin-bottom: 9px; -} - -.natural-result__checks { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; -} - -.natural-result__checks span { - display: grid; - grid-template-columns: 17px 1fr; - gap: 3px 7px; - align-items: center; - padding: 8px; - border-radius: 7px; - background: #edf1ec; -} - -.natural-result__checks i { - display: grid; - width: 17px; - height: 17px; - grid-row: 1 / 3; - place-items: center; - border-radius: 5px; - color: #31573a; - background: #dce8dd; - font-size: 7px; - font-style: normal; -} - -.natural-result__checks b { - font-size: 7px; -} - -.natural-result__checks small { - color: #7b837c; - font-size: 6px; -} - -.natural-result__options { - grid-template-columns: 1fr; -} - -.natural-result__saved { - display: flex; - align-items: center; - gap: 10px; - padding: 11px; - border: 1px solid rgba(51, 88, 60, .13); - border-radius: 9px; - background: #e8efe8; -} - -.natural-result__saved > i { - display: grid; - width: 24px; - height: 24px; - place-items: center; - border-radius: 7px; - color: #f0f5f0; - background: #45694d; - font-style: normal; -} - -.natural-result__saved > span { - display: grid; - gap: 3px; -} - -.natural-result__saved b { - font-size: 8px; -} - -.natural-result__saved small { - color: #6f7c72; - font-size: 7px; -} - -@keyframes studio-mode-enter { - from { - opacity: 0; - filter: blur(10px); - transform: translateY(16px) scale(.985); - } - -} - -@keyframes natural-glyph-signal { - 0%, 100% { - opacity: .2; - transform: scale(.7); - } - - 38% { - opacity: 1; - transform: scale(1.25); - background: #dce6de; - } - -} - -@keyframes natural-dot-resolve { - 0%, 100% { - opacity: .08; - transform: scale(.62); - } - - 42% { - opacity: .78; - transform: scale(1.18); - } - -} - -@keyframes natural-image-resolve { - from { - opacity: .08; - filter: blur(18px) grayscale(.5); - transform: scale(.86); - } - - to { - opacity: 1; - filter: none; - transform: none; - } - -} - -@keyframes natural-frame-enter { - from { - opacity: 0; - filter: blur(8px); - transform: translateY(6px) scale(.9); - } - -} - -@keyframes natural-step-pulse { - to { - box-shadow: 0 0 0 8px rgba(56, 90, 66, .14); - transform: scale(1.05); - } - -} - -@keyframes natural-result-mark { - from { - opacity: 0; - transform: scale(.45) rotate(-18deg); - } - -} - -@media (max-width: 1040px) { - body:has(.studio-mode-gateway), body:has(.natural-creation) { - overflow: auto; - } - - .studio-mode-gateway { - min-height: 100svh; - } - - .studio-mode-gateway__choices { - grid-template-columns: 1fr; - } - - .studio-mode-card { - min-height: 340px; - } - - .natural-creation--input { - grid-template-columns: 1fr; - } - - .natural-creation__aside { - min-height: 620px; - } - - .natural-command-form { - min-height: 680px; - } - - .natural-progress__workspace, .natural-result__body { - grid-template-columns: 1fr; - } - - .natural-progress__visual { - min-height: 560px; - } - - .natural-result__assets { - min-height: 580px; - } - -} - -@media (max-width: 700px) { - .studio-mode-gateway { - padding: 92px 18px 28px; - } - - .studio-mode-gateway__header h1 { - font-size: 37px; - } - - .studio-mode-card { - min-height: 320px; - padding: 26px; - } - - .natural-creation__aside { - min-height: 560px; - padding: 94px 28px 34px; - } - - .natural-command-form { - min-height: 700px; - padding: 58px 24px 30px; - } - - .natural-command-form > footer { - align-items: stretch; - flex-direction: column-reverse; - } - - .natural-command-form > footer button { - width: 100%; - justify-content: space-between; - } - - .natural-creation--progress, .natural-creation--result { - padding: 86px 14px 20px; - } - - .natural-progress__header { - align-items: flex-start; - flex-direction: column; - } - - .natural-progress__visual { - min-height: 480px; - } - - .natural-progress__master > img { - width: 62%; - } - - .natural-progress__filmstrip { - grid-template-columns: repeat(4, 1fr); - } - - .natural-result__assets { - min-height: auto; - grid-template-columns: 1fr; - } - - .natural-result__master { - min-height: 360px; - } - -} - -@media (prefers-reduced-motion: reduce) { - .studio-mode-gateway, .natural-creation, .studio-mode-card, .studio-mode-card::after, .studio-mode-card__glyph--natural i, .natural-progress__resolve i, .natural-progress__master > img, .natural-progress__filmstrip span, .natural-progress__steps li.is-running > i { - animation: none !important; - transition: none !important; - } - -} - -/* Reference-led AI creation canvas - The new quick-creation screens share one dark stage, compact navigation, - central prompt rhythm and a fixed command dock. */ -.workflow-app:has(.studio-mode-gateway), .workflow-app:has(.natural-agent-screen) { - --agent-accent: #35583f; - --agent-accent-strong: #263f2d; - --agent-canvas: var(--workspace-white); - --agent-panel: #eef1ed; - --agent-panel-raised: #f7f8f4; - --agent-line: rgba(38, 63, 45, .16); - --agent-muted: #747973; - --agent-text: #171817; - min-height: 100svh; - color: var(--agent-text); - background: var(--agent-canvas); - font-family: Bahnschrift, "Microsoft YaHei UI", "Noto Sans SC", sans-serif; -} - -body:has(.studio-mode-gateway), body:has(.natural-agent-screen) { - color: var(--agent-text); - background: var(--agent-canvas); -} - -.production-canvas-workspace:has(> .studio-mode-gateway), .production-canvas-workspace:has(> .natural-agent-screen) { - min-height: 720px; - color: var(--agent-text); - background: var(--agent-canvas); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar, .workflow-app:has(.natural-agent-screen) .studio-bar { - top: 0; - min-height: 74px; - padding: 0 28px; - border-bottom: 1px solid var(--agent-line); - color: var(--agent-text); - background: rgba(223, 227, 223, .96); - box-shadow: none; - backdrop-filter: blur(18px); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__left, .workflow-app:has(.natural-agent-screen) .studio-bar__left, .workflow-app:has(.studio-mode-gateway) .studio-bar__right, .workflow-app:has(.natural-agent-screen) .studio-bar__right { - min-height: 73px; - padding: 0; - border: 0; - border-radius: 0; - color: var(--agent-text); - background: transparent; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__left, .workflow-app:has(.natural-agent-screen) .studio-bar__left { - min-width: min(360px, 36vw); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__brand, .workflow-app:has(.natural-agent-screen) .studio-bar__brand { - color: var(--agent-text); - border-right-color: var(--agent-line); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__left .product-brand__mark, .workflow-app:has(.natural-agent-screen) .studio-bar__left .product-brand__mark { - background: var(--agent-text); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__brand b, .workflow-app:has(.natural-agent-screen) .studio-bar__brand b { - font: 700 16px/1 Bahnschrift, "Microsoft YaHei UI", sans-serif; - letter-spacing: .04em; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__project b, .workflow-app:has(.natural-agent-screen) .studio-bar__project b { - color: #26302a; - font-size: 11px; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__project small, .workflow-app:has(.natural-agent-screen) .studio-bar__project small { - color: var(--agent-muted); - font-size: 8px; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__nav a, .workflow-app:has(.natural-agent-screen) .studio-bar__nav a, .workflow-app:has(.studio-mode-gateway) .studio-bar__actions button, .workflow-app:has(.natural-agent-screen) .studio-bar__actions button { - min-height: 38px; - padding: 0 13px; - border-radius: 9px; - color: #626a64; - font-size: 10px; -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__nav a:hover, .workflow-app:has(.natural-agent-screen) .studio-bar__nav a:hover, .workflow-app:has(.studio-mode-gateway) .studio-bar__actions button:hover, .workflow-app:has(.natural-agent-screen) .studio-bar__actions button:hover, .workflow-app:has(.studio-mode-gateway) .studio-bar__nav a.is-active, .workflow-app:has(.natural-agent-screen) .studio-bar__nav a.is-active { - color: var(--agent-accent); - background: rgba(255, 255, 255, .54); -} - -.workflow-app:has(.studio-mode-gateway) .studio-bar__actions, .workflow-app:has(.natural-agent-screen) .studio-bar__actions { - border-left-color: var(--agent-line); -} - -.studio-bar__mode-back { - min-width: 48px; - min-height: 40px; - padding: 0 14px; - border: 1px solid rgba(31, 46, 35, .18); - border-radius: 10px; - color: #354039; - background: rgba(255, 255, 255, .62); - font: 700 13px/1 "Microsoft YaHei UI", sans-serif; -} - -.studio-bar__mode-back:hover, .studio-bar__mode-back:focus-visible { - border-color: rgba(44, 90, 56, .3); - color: #294e34; - background: #f5f7f4; -} - -.studio-mode-gateway { - min-height: 100svh; - gap: clamp(24px, 4vh, 54px); - padding: 118px clamp(34px, 7vw, 130px) 44px; - color: var(--agent-text); - background: var(--agent-canvas); -} - -.studio-mode-gateway::before { - display: none; -} - -.studio-mode-gateway__header { - max-width: 780px; - gap: 12px; -} - -.studio-mode-gateway__back { - display: inline-flex; - width: max-content; - min-height: 44px; - align-items: center; - padding: 0 16px; - border: 1px solid var(--agent-line); - border-radius: 12px; - color: #354039; - background: rgba(255, 255, 255, .62); - font-size: 13px; - font-weight: 700; -} - -.studio-mode-gateway__back:hover, .studio-mode-gateway__back:focus-visible { - border-color: rgba(86, 230, 174, .42); - color: var(--agent-accent); -} - -.studio-mode-gateway__header .overline { - color: var(--agent-accent); - border-color: rgba(53, 88, 63, .28); - background: rgba(255, 255, 255, .58); -} - -.studio-mode-gateway__header h1 { - color: var(--agent-text); - font: 650 clamp(38px, 4vw, 62px)/1.08 Bahnschrift, "Microsoft YaHei UI", sans-serif; - letter-spacing: -.04em; -} - -.studio-mode-gateway__header p { - max-width: 700px; - color: #687069; - font-size: 14px; - line-height: 1.7; -} - -.studio-mode-gateway__choices { - gap: 18px; -} - -.studio-mode-card { - min-height: clamp(330px, 45vh, 500px); - padding: clamp(28px, 4vw, 52px); - border-color: var(--agent-line); - border-radius: 22px; - color: var(--agent-text); - background: #f6f7f3; - box-shadow: 0 28px 80px rgba(31, 43, 35, .09); -} - -.studio-mode-card::after { - display: none; -} - -.studio-mode-card:hover, .studio-mode-card:focus-visible { - border-color: rgba(53, 88, 63, .38); - background: #fbfcf8; - box-shadow: 0 0 0 1px rgba(53, 88, 63, .08), 0 30px 90px rgba(31, 43, 35, .13); -} - -.studio-mode-card__index, .studio-mode-card__copy small { - color: #747973; -} - -.studio-mode-card__eyebrow { - align-self: start; - color: var(--agent-accent); - font: 700 10px/1 Bahnschrift, sans-serif; - letter-spacing: .12em; -} - -.studio-mode-card__index { - justify-self: end; -} - -.studio-mode-card__copy b { - color: var(--agent-text); - font: 650 clamp(24px, 2.2vw, 34px)/1.2 Bahnschrift, "Microsoft YaHei UI", sans-serif; -} - -.studio-mode-card__copy p { - color: #687069; - font-size: 13px; - line-height: 1.8; -} - -.studio-mode-card__action { - min-height: 44px; - color: var(--agent-accent); - font-size: 12px; -} - -.studio-mode-card__glyph { - filter: grayscale(1); -} - -.studio-mode-gateway__note { - color: #929995; -} - -.studio-mode-gateway__note > i { - background: var(--agent-accent); -} - -.natural-agent-screen { - position: relative; - display: block; - width: 100%; - min-height: 100svh; - padding: 74px 0 0; - overflow: hidden; - color: var(--agent-text); - background: var(--agent-canvas); - animation: agent-screen-enter 440ms ease both; -} - -.natural-agent-screen[data-agent-settled] { - animation: none; -} -.natural-agent-screen[data-agent-settled] .is-arrived { - animation: none; -} - -.natural-agent-header { - position: absolute; - z-index: 8; - top: 102px; - right: clamp(24px, 4vw, 66px); - left: clamp(24px, 4vw, 66px); - display: grid; - grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr); - align-items: center; -} - -.natural-agent-back { - display: inline-grid; - width: max-content; - min-height: 52px; - grid-template-columns: auto auto; - gap: 13px; - align-items: center; - padding: 0 17px; - border: 1px solid var(--agent-line); - border-radius: 13px; - color: #26302a; - background: rgba(248, 249, 245, .82); - font-family: inherit; -} - -.natural-agent-back span { - padding-right: 13px; - border-right: 1px solid var(--agent-line); - color: #747973; - font-size: 12px; - font-weight: 650; -} - -.natural-agent-back b { - font-size: 15px; - letter-spacing: .01em; -} - -.natural-agent-back:hover, .natural-agent-back:focus-visible { - border-color: rgba(53, 88, 63, .36); - color: var(--agent-accent); - background: #fbfcf8; -} - -.natural-agent-switch { - display: flex; - min-height: 54px; - align-items: center; - gap: 4px; - padding: 5px; - border: 1px solid var(--agent-line); - border-radius: 999px; - background: rgba(246, 248, 244, .88); -} - -.natural-agent-switch button { - min-width: 145px; - min-height: 42px; - padding: 0 20px; - border: 1px solid transparent; - border-radius: 999px; - color: #747973; - background: transparent; - font: 700 12px/1 Bahnschrift, "Microsoft YaHei UI", sans-serif; - letter-spacing: .04em; -} - -.natural-agent-switch button.is-active { - border-color: rgba(53, 88, 63, .46); - color: var(--agent-accent); - background: #e4ebe2; - box-shadow: inset 0 0 20px rgba(53, 88, 63, .04); - opacity: 1; -} - -.natural-agent-switch button:not(.is-active):hover, .natural-agent-switch button:not(.is-active):focus-visible { - color: #26302a; -} - -.natural-agent-header__spacer { - min-width: 180px; -} - -.natural-agent-suggestion { - position: absolute; - z-index: 3; - top: 46%; - left: 50%; - display: flex; - width: min(940px, 72vw); - min-height: 86px; - align-items: center; - gap: 24px; - justify-content: center; - padding: 18px 38px; - border: 1px solid var(--agent-line); - border-radius: 999px; - color: var(--agent-text); - background: #f7f8f4; - box-shadow: 0 22px 48px rgba(31, 43, 35, .1); - font-family: inherit; - transform: translate(-50%, -50%); -} - -.natural-agent-suggestion span { - color: #747973; - font-size: clamp(14px, 1.2vw, 20px); - font-weight: 650; - white-space: nowrap; -} - -.natural-agent-suggestion b { - color: var(--agent-accent); - font-size: clamp(17px, 1.6vw, 26px); - font-weight: 650; - letter-spacing: .01em; - text-align: left; -} - -.natural-agent-suggestion:hover, .natural-agent-suggestion:focus-visible { - border-color: rgba(53, 88, 63, .34); - background: #fbfcf8; -} - -.natural-agent-composer { - position: absolute; - z-index: 7; - bottom: 34px; - left: 50%; - display: grid; - width: min(820px, 70vw); - min-height: 106px; - grid-template-columns: 1fr auto; - grid-template-rows: 1fr auto; - gap: 5px 16px; - padding: 18px 18px 14px 24px; - border: 1px solid var(--agent-line); - border-radius: 22px; - background: var(--agent-panel-raised); - box-shadow: 0 24px 70px rgba(31, 43, 35, .13); - transform: translateX(-50%); -} - -.natural-agent-composer__field { - display: grid; - min-width: 0; - grid-column: 1; - grid-row: 1; -} - -.natural-agent-composer__field > span { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -.natural-agent-composer textarea { - width: 100%; - min-height: 48px; - resize: none; - border: 0; - outline: 0; - color: #1d251f; - background: transparent; - font: 500 15px/1.55 "Microsoft YaHei UI", "Noto Sans SC", sans-serif; -} - -.natural-agent-composer textarea::placeholder { - color: #7a817b; -} - -.natural-agent-composer:focus-within { - border-color: rgba(53, 88, 63, .32); - box-shadow: 0 0 0 3px rgba(53, 88, 63, .06), 0 24px 70px rgba(31, 43, 35, .13); -} - -.natural-agent-composer__meta { - display: flex; - grid-column: 1; - grid-row: 2; - align-items: center; - gap: 10px; -} - -.natural-agent-composer__meta span { - padding: 6px 10px; - border: 1px solid var(--agent-line); - border-radius: 999px; - color: #515a53; - background: #e7ebe5; - font-size: 10px; -} - -.natural-agent-composer__meta small { - color: #747973; - font-size: 9px; -} - -.natural-agent-composer__submit { - min-width: 108px; - min-height: 70px; - grid-column: 2; - grid-row: 1 / 3; - align-self: stretch; - padding: 0 18px; - border-radius: 17px; - color: #f3f6f2; - background: var(--agent-accent); - font: 750 12px/1 "Microsoft YaHei UI", sans-serif; -} - -.natural-agent-composer__submit:hover, .natural-agent-composer__submit:focus-visible { - background: #456c51; - transform: translateY(-1px); -} - -.natural-agent-composer .natural-command-form__error { - position: absolute; - bottom: calc(100% + 8px); - left: 0; - margin: 0; - padding: 8px 12px; - border-radius: 8px; - color: #ffb8ad; - background: #311b19; - font-size: 11px; -} - -.natural-agent-character-picker { - position: absolute; - z-index: 6; - bottom: 160px; - left: 50%; - display: flex; - gap: 14px; - padding: 10px 20px; - transform: translateX(-50%); -} - -.natural-agent-character-picker button { - display: grid; - width: 64px; - grid-template-rows: 48px auto; - gap: 4px; - padding: 6px; - border: 1px solid var(--agent-line); - border-radius: 12px; - color: #687069; - background: rgba(245, 247, 243, .92); - font-family: inherit; - cursor: pointer; - transition: border-color 180ms, background 180ms, transform 180ms; -} - -.natural-agent-character-picker button:hover, -.natural-agent-character-picker button:focus-visible { - border-color: rgba(53, 88, 63, .34); - background: #f0f4ee; - transform: translateY(-2px); -} - -.natural-agent-character-picker button.is-active { - border-color: var(--agent-accent, #35583f); - background: #e4ebe2; - color: var(--agent-accent, #35583f); -} - -.natural-agent-character-picker button img { - width: 100%; - height: 48px; - object-fit: contain; - image-rendering: pixelated; - border-radius: 6px; - background: rgba(0, 0, 0, .03); -} - -.natural-agent-character-picker button span { - color: inherit; - font-size: 8px; - font-weight: 700; - text-align: center; - letter-spacing: .04em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - - -.natural-agent-examples { - position: absolute; - z-index: 5; - bottom: 240px; - left: 50%; - display: flex; - gap: 8px; - transform: translateX(-50%); -} - -.natural-agent-examples button { - min-height: 32px; - padding: 0 12px; - border: 1px solid var(--agent-line); - border-radius: 999px; - color: #687069; - background: #f3f5f1; - font-size: 9px; -} - -.natural-agent-examples button:hover, .natural-agent-examples button:focus-visible { - color: var(--agent-accent); - border-color: rgba(86, 230, 174, .3); -} - -.natural-agent-preview { - position: absolute; - right: 22px; - bottom: 26px; - display: grid; - width: 184px; - min-height: 168px; - grid-template-rows: auto 1fr; - gap: 8px; - padding: 11px; - overflow: hidden; - border: 1px solid rgba(53, 88, 63, .22); - border-radius: 17px; - background: #f5f7f3; -} - -.natural-agent-preview > span { - display: grid; - gap: 2px; -} - -.natural-agent-preview b { - color: #58625a; - font-size: 8px; - letter-spacing: .1em; -} - -.natural-agent-preview small { - color: #858c86; - font-size: 7px; -} - -.natural-agent-preview img { - width: 100%; - min-height: 112px; - object-fit: contain; - image-rendering: pixelated; - background: #e9ece8; -} - -.natural-agent-live { - display: inline-flex; - justify-self: end; - min-width: 132px; - min-height: 46px; - align-items: center; - gap: 10px; - padding: 0 14px; - border: 1px solid var(--agent-line); - border-radius: 11px; - color: #5f6861; - background: rgba(248, 249, 245, .82); -} - -.natural-agent-live > i { - width: 8px; - height: 8px; - border-radius: 50%; - background: #4f7b5b; - box-shadow: 0 0 0 5px rgba(79, 123, 91, .12); - animation: node-pending-pulse 1.1s ease-in-out infinite alternate; -} - -.natural-agent-live > span { - display: grid; - gap: 3px; -} - -.natural-agent-live small { - color: #747973; - font: 700 7px/1 monospace; - letter-spacing: .1em; -} - -.natural-agent-live b { - color: var(--agent-accent); - font: 700 12px/1 Bahnschrift, "Microsoft YaHei UI", sans-serif; -} - -.natural-agent-stage { - position: absolute; - z-index: 2; - inset: 184px 7vw 190px; - display: grid; - min-height: 0; - place-items: center; -} - -.natural-agent-stage .natural-progress__visual { - width: min(820px, 65vw); - height: min(500px, 50vh); - min-height: 360px; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.natural-agent-stage .natural-live-board { - display: grid; - grid-template-columns: minmax(170px, .7fr) minmax(0, 1.3fr); - grid-template-rows: auto minmax(0, 1fr); - gap: 12px; - padding: 14px; - border: 1px solid var(--agent-line); - border-radius: 22px; - background: #f4f6f2; - box-shadow: 0 30px 100px rgba(31, 43, 35, .12); -} - -.natural-live-board__header { - display: flex; - grid-column: 1 / -1; - align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 38px; - padding: 0 4px; -} - -.natural-live-board__header > span { - display: grid; - gap: 3px; -} - -.natural-live-board__header small { - color: #7a817b; - font: 700 7px/1 monospace; - letter-spacing: .12em; -} - -.natural-live-board__header b { - color: #26302a; - font: 650 13px/1.2 Bahnschrift, "Microsoft YaHei UI", sans-serif; -} - -.natural-live-board__header strong { - color: var(--agent-accent); - font-size: 9px; -} - -.natural-live-master { - position: relative; - display: grid; - min-height: 0; - place-items: center; - overflow: hidden; - border: 1px solid var(--agent-line); - border-radius: 14px; - background: #e9ede8; -} - -.natural-live-master > img { - position: relative; - z-index: 2; - width: min(78%, 230px); - height: min(78%, 230px); - object-fit: contain; - image-rendering: pixelated; - animation: natural-frame-enter 420ms ease both; -} - -.natural-live-master > span { - position: absolute; - z-index: 3; - right: 12px; - bottom: 10px; - left: 12px; - display: grid; - gap: 3px; -} - -.natural-live-master > span small { - color: #718078; - font: 700 6px/1 monospace; - letter-spacing: .1em; -} - -.natural-live-master > span b { - overflow: hidden; - color: #354039; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.natural-live-sequences { - display: grid; - min-width: 0; - min-height: 0; - grid-template-rows: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.natural-live-rail { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - gap: 6px; - padding: 9px; - border: 1px solid var(--agent-line); - border-radius: 13px; - background: #eef1ed; -} - -.natural-live-rail > header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.natural-live-rail > header > span { - display: flex; - align-items: baseline; - gap: 8px; -} - -.natural-live-rail > header small { - color: #748078; - font: 700 6px/1 monospace; - letter-spacing: .1em; -} - -.natural-live-rail > header b { - color: #354039; - font-size: 8px; -} - -.natural-live-rail > header strong { - color: var(--agent-accent); - font: 700 8px/1 monospace; -} - -.natural-live-rail__frames { - display: grid; - min-height: 0; - grid-template-columns: repeat(8, minmax(0, 1fr)); - gap: 4px; -} - -.natural-live-rail__frames > span { - position: relative; - display: grid; - min-width: 0; - aspect-ratio: 1; - place-items: center; - overflow: hidden; - border: 1px solid rgba(53, 88, 63, .12); - border-radius: 6px; - background: #e5e9e4; -} - -.natural-live-rail__frames > span.is-pending { - border-style: dashed; -} - -.natural-live-rail__frames > span.is-pending > i { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(69, 108, 81, .24); - animation: node-pending-pulse 1.1s ease-in-out infinite alternate; -} - -.natural-live-rail__frames > span.is-arrived { - animation: natural-frame-enter 420ms ease both; -} - -.natural-live-rail__frames img { - width: 100%; - height: 100%; - object-fit: contain; - image-rendering: pixelated; -} - -.natural-live-rail__frames small { - position: absolute; - right: 3px; - bottom: 2px; - color: #687069; - font: 700 5px/1 monospace; -} - -.natural-agent-stage .natural-progress__master { - border: 1px solid var(--agent-line); - border-radius: 22px; - background: #eef1ed; - box-shadow: 0 30px 100px rgba(31, 43, 35, .12); -} - -.natural-agent-stage .natural-progress__master > img { - width: min(40%, 280px); -} - -.natural-agent-stage .natural-progress__master > span { - bottom: 24px; - left: 26px; -} - -.natural-agent-stage .natural-progress__master > span small { - color: var(--agent-accent); -} - -.natural-agent-stage .natural-progress__master > span b { - color: #26302a; - font: 650 17px/1.2 Bahnschrift, "Microsoft YaHei UI", sans-serif; -} - -.natural-agent-stage .natural-progress__resolve i { - background: var(--agent-accent-strong); -} - -.natural-agent-stage .natural-progress__filmstrip { - position: absolute; - right: -38px; - bottom: 24px; - width: min(380px, 42vw); - padding: 10px; - border: 1px solid var(--agent-line); - border-radius: 14px; - background: rgba(246, 248, 244, .95); -} - -.natural-agent-runner { - position: fixed; - z-index: 5; - bottom: 28px; - left: 50%; - display: grid; - width: min(880px, 72vw); - gap: 10px; - padding: 16px 18px; - border: 1px solid var(--agent-line); - border-radius: 20px; - background: rgba(247, 249, 246, .97); - box-shadow: 0 25px 70px rgba(31, 43, 35, .14); - transform: translateX(-50%); -} - -.natural-agent-runner > header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; -} - -.natural-agent-runner > header span { - display: grid; - gap: 3px; -} - -.natural-agent-runner > header small { - color: #747973; - font-size: 7px; - letter-spacing: .12em; -} - -.natural-agent-runner > header b { - color: #26302a; - font-size: 13px; -} - -.natural-agent-runner > header strong { - color: var(--agent-accent); - font-size: 18px; -} - -.natural-agent-runner > p { - margin: 0; - color: #687069; - font-size: 10px; -} - -.natural-agent-runner .natural-progress__bar { - padding: 0; - background: transparent; -} - -.natural-agent-runner .natural-progress__bar progress { - height: 7px; - background: #d9ded8; -} - -.natural-agent-runner .natural-progress__bar progress::-webkit-progress-bar { - background: #d9ded8; -} - -.natural-agent-runner .natural-progress__bar progress::-webkit-progress-value { - background: var(--agent-accent); -} - -.natural-agent-runner .natural-progress__bar progress::-moz-progress-bar { - background: var(--agent-accent); -} - -.natural-agent-runner .natural-progress__steps { - grid-template-columns: repeat(5, 1fr); - gap: 6px; -} - -.natural-agent-runner .natural-progress__steps li { - gap: 6px; - padding: 8px; - border-color: rgba(38, 63, 45, .09); - background: #edf0ec; -} - -.natural-agent-runner .natural-progress__steps li b { - color: #515a53; - font-size: 8px; -} - -.natural-agent-runner .natural-progress__steps li small, .natural-agent-runner .natural-progress__steps li em { - display: none; -} - -.natural-agent-runner .natural-progress__steps li > i { - color: #697169; - border-color: rgba(38, 63, 45, .14); - background: #f7f8f4; -} - -.natural-agent-runner .natural-progress__steps li.is-running > i, .natural-agent-runner .natural-progress__steps li.is-completed > i { - color: #f3f6f2; - border-color: var(--agent-accent); - background: var(--agent-accent); -} - -.natural-agent-runner .natural-progress__intent { - display: none; -} - -.natural-agent-result-stage { - position: absolute; - inset: 190px 5vw 36px; - display: grid; - min-height: 0; - grid-template-columns: minmax(0, 1.25fr) minmax(340px, .75fr); - gap: 18px; -} - -.natural-agent-result-stage .natural-result__assets, .natural-agent-delivery { - min-height: 0; - border: 1px solid var(--agent-line); - border-radius: 20px; - color: var(--agent-text); - background: #f6f7f3; - box-shadow: 0 28px 80px rgba(31, 43, 35, .11); -} - -.natural-agent-result-stage .natural-result__assets { - padding: 18px; -} - -.natural-agent-result-stage .natural-result__master { - background: #e9ece8; -} - -.natural-agent-result-stage .natural-result__master figcaption { - border-top-color: var(--agent-line); -} - -.natural-agent-result-stage .natural-result__master b, .natural-agent-result-stage .natural-result__sequences > header b { - color: #26302a; -} - -.natural-agent-result-stage .natural-result__master small, .natural-agent-result-stage .natural-result__sequences > header small, .natural-agent-result-stage .natural-result__sequences > header i { - color: #747973; -} - -.natural-agent-result-stage .node-frame-strip span { - background: #e9ece8; -} - -.natural-agent-delivery { - display: grid; - min-width: 0; - align-content: start; - gap: 14px; - padding: 22px; - overflow: auto; -} - -.natural-agent-delivery > header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.natural-agent-delivery > header span { - display: grid; - min-width: 0; - gap: 5px; -} - -.natural-agent-delivery > header small { - color: var(--agent-accent); - font-size: 8px; - letter-spacing: .12em; -} - -.natural-agent-delivery h1 { - margin: 0; - overflow: hidden; - color: #26302a; - font: 650 clamp(21px, 2vw, 30px)/1.16 Bahnschrift, "Microsoft YaHei UI", sans-serif; - text-overflow: ellipsis; - white-space: nowrap; -} - -.natural-agent-delivery__badge { - flex-shrink: 0; - display: grid; - min-width: 52px; - min-height: 30px; - place-items: center; - border: 1px solid rgba(53, 88, 63, .24); - border-radius: 999px; - color: var(--agent-accent); - background: #e4ebe2; - font-size: 11px; - font-style: normal; - font-weight: 600; - white-space: nowrap; -} - -.natural-agent-delivery > p { - margin: 0; - color: #687069; - font-size: 11px; - line-height: 1.65; -} - -.natural-agent-delivery .natural-result__checks { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.natural-agent-delivery .natural-result__checks span { - grid-template-columns: 36px 1fr; - background: #e9ede8; -} - -.natural-agent-delivery .natural-result__checks i { - width: 36px; - color: var(--agent-accent); - background: #dde8df; - font-size: 7px; -} - -.natural-agent-delivery .natural-result__checks b { - color: #354039; - font-size: 9px; -} - -.natural-agent-delivery .natural-result__options button, .natural-agent-delivery .natural-result__options a { - border-color: var(--agent-line); - color: #26302a; - background: #edf0ec; -} - -.natural-agent-delivery .natural-result__options button:hover, .natural-agent-delivery .natural-result__options a:hover { - border-color: rgba(53, 88, 63, .32); - background: #f7f8f4; -} - -.natural-agent-delivery .natural-result__options small { - color: #747973; -} - -.natural-agent-delivery .workflow-save-form { - border-color: var(--agent-line); - background: #edf0ec; -} - -.natural-agent-delivery .workflow-save-form span, .natural-agent-delivery .workflow-save-form small { - color: #687069; -} - -.natural-agent-delivery .workflow-save-form input { - border-color: var(--agent-line); - color: #26302a; - background: #f9faf6; -} - -.natural-agent-delivery .workflow-save-form button, .natural-agent-delivery > footer .button--primary { - min-height: 42px; - border-radius: 10px; - color: #f3f6f2; - background: var(--agent-accent); - font-size: 11px; -} - -.natural-agent-delivery .natural-result__saved { - border-color: rgba(53, 88, 63, .2); - background: #e4ebe2; -} - -.natural-agent-delivery .natural-result__saved > i { - color: #f3f6f2; - background: var(--agent-accent); -} - -.natural-agent-delivery .natural-result__saved b { - color: #354039; -} - -.natural-agent-delivery > footer { - border-top-color: var(--agent-line); -} - -@keyframes agent-screen-enter { - from { - opacity: 0; - transform: translateY(8px); - } - -} - -@media (max-width: 1120px) { - .workflow-app:has(.studio-mode-gateway) .studio-bar__project, .workflow-app:has(.natural-agent-screen) .studio-bar__project { - display: none; - } - - .natural-agent-preview { - display: none; - } - - .natural-agent-composer { - width: min(760px, 78vw); - } - - .natural-agent-result-stage { - grid-template-columns: 1fr; - overflow: auto; - } - - .natural-agent-result-stage .natural-result__assets { - min-height: 520px; - } - - .natural-agent-delivery { - min-height: 560px; - } - -} - -@media (max-width: 760px) { - body:has(.studio-mode-gateway), body:has(.natural-agent-screen) { - overflow: auto; - } - - .workflow-app:has(.studio-mode-gateway) .studio-bar, .workflow-app:has(.natural-agent-screen) .studio-bar { - min-height: 64px; - padding: 0 14px; - } - - .workflow-app:has(.studio-mode-gateway) .studio-bar__left, .workflow-app:has(.natural-agent-screen) .studio-bar__left { - min-width: 0; - } - - .workflow-app:has(.studio-mode-gateway) .studio-bar__project, .workflow-app:has(.natural-agent-screen) .studio-bar__project, .workflow-app:has(.studio-mode-gateway) .studio-bar__nav, .workflow-app:has(.natural-agent-screen) .studio-bar__nav, .workflow-app:has(.studio-mode-gateway) .studio-bar__actions button:not(:first-child), .workflow-app:has(.natural-agent-screen) .studio-bar__actions button:not(:first-child) { - display: none; - } - - .studio-mode-gateway { - min-height: 100svh; - padding: 94px 18px 28px; - } - - .studio-mode-gateway__header h1 { - font-size: 38px; - } - - .studio-mode-card { - min-height: 320px; - } - - .natural-agent-screen { - min-height: 760px; - padding-top: 64px; - overflow: auto; - } - - .natural-agent-header { - top: 84px; - right: 16px; - left: 16px; - grid-template-columns: 1fr; - gap: 12px; - } - - .natural-agent-back { - min-height: 46px; - padding: 0 14px; - } - - .natural-agent-switch { - width: 100%; - } - - .natural-agent-switch button { - min-width: 0; - flex: 1; - } - - .natural-agent-header__spacer, .natural-agent-live { - display: none; - } - - .natural-agent-suggestion { - top: 38%; - width: calc(100% - 32px); - min-height: 92px; - align-items: flex-start; - flex-direction: column; - gap: 7px; - padding: 18px 22px; - border-radius: 20px; - } - - .natural-agent-suggestion span, .natural-agent-suggestion b { - font-size: 14px; - white-space: normal; - } - - .natural-agent-composer { - bottom: 20px; - width: calc(100% - 32px); - min-height: 126px; - grid-template-columns: 1fr 90px; - padding: 15px; - } - - .natural-agent-composer__submit { - min-width: 90px; - } - - .natural-agent-examples { - display: none; - } - - .natural-agent-character-picker { - bottom: 152px; - gap: 6px; - padding: 8px 12px; - } - - .natural-agent-character-picker button { - width: 50px; - grid-template-rows: 38px auto; - gap: 2px; - padding: 4px; - } - - .natural-agent-character-picker button img { - height: 38px; - } - - .natural-agent-stage { - inset: 210px 16px 180px; - } - - .natural-agent-stage .natural-progress__visual { - width: 100%; - height: min(390px, 46vh); - min-height: 330px; - } - - .natural-agent-stage .natural-live-board { - grid-template-columns: 96px minmax(0, 1fr); - gap: 8px; - padding: 9px; - } - - .natural-live-board__header { - min-height: 28px; - } - - .natural-live-rail { - gap: 4px; - padding: 6px; - } - - .natural-agent-stage .natural-progress__master > img { - width: 58%; - } - - .natural-agent-stage .natural-progress__filmstrip { - right: 10px; - bottom: 16px; - width: calc(100% - 20px); - } - - .natural-agent-runner { - bottom: 18px; - width: calc(100% - 32px); - } - - .natural-agent-runner .natural-progress__steps { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .natural-agent-runner .natural-progress__steps li { - display: grid; - place-items: center; - padding: 6px 2px; - } - - .natural-agent-runner .natural-progress__steps li span { - display: none; - } - - .natural-agent-result-stage { - inset: 210px 16px 20px; - } - - .natural-agent-result-stage .natural-result__assets { - min-height: 620px; - grid-template-columns: 1fr; - } - -} - -/* Pointer-aware card feedback keeps the existing light Studio palette while - adding a small spatial response for mouse users. */ -[data-pointer-card] { - --card-lift: 0px; - --card-scale: 1; - --pointer-glow-opacity: 0; - --pointer-rotate-x: 0deg; - --pointer-rotate-y: 0deg; - --pointer-x: 50%; - --pointer-y: 50%; - position: relative; - isolation: isolate; - transform: perspective(1000px) translateY(var(--card-lift)) rotateX(var(--pointer-rotate-x)) rotateY(var(--pointer-rotate-y)) scale(var(--card-scale)); - transform-style: preserve-3d; - transition-property: transform, border-color, background-color, box-shadow; - transition-duration: 170ms, 220ms, 220ms, 220ms; - transition-timing-function: cubic-bezier(.22, 1, .36, 1); -} - -[data-pointer-card]::before { - position: absolute; - z-index: 2; - inset: 0; - border-radius: inherit; - content: ""; - background: radial-gradient(circle at var(--pointer-x) var(--pointer-y), rgba(75, 111, 84, .14), transparent 42%); - opacity: var(--pointer-glow-opacity); - transition: opacity 180ms ease-out; - pointer-events: none; -} - -[data-pointer-card].is-pointer-active { - --pointer-glow-opacity: 1; -} - -button[data-pointer-card]:hover, button[data-pointer-card]:focus-visible { - --card-lift: -5px; -} - -button[data-pointer-card] { - cursor: pointer; -} - -button[data-pointer-card]:active { - --card-lift: -1px; - --card-scale: .985; -} - -.natural-agent-preview[data-pointer-card], .natural-progress__master[data-pointer-card], .natural-result__master[data-pointer-card] { - transition-duration: 200ms, 220ms, 220ms, 220ms; -} - -.natural-agent-suggestion, .natural-agent-composer__submit, .natural-agent-back, .natural-agent-switch button, .natural-agent-examples button, .natural-result__options button, .natural-result__options a { - transition-property: transform, border-color, color, background-color, box-shadow; - transition-duration: 160ms; - transition-timing-function: cubic-bezier(.22, 1, .36, 1); -} - -.natural-agent-suggestion:active { - transform: translate(-50%, -50%) scale(.99); -} - -.natural-agent-composer__submit:active, .natural-agent-back:active, .natural-agent-switch button:active, .natural-agent-examples button:active, .natural-agent-character-picker button:active, .natural-result__options button:active, .natural-result__options a:active { - transform: scale(.97); -} - -.natural-agent-preview, .natural-progress__master, .natural-result__master, .natural-result__sequences { - animation: natural-card-settle 420ms cubic-bezier(.22, 1, .36, 1) both; -} - -.natural-result__sequences { - animation-delay: 70ms; -} - -@keyframes natural-card-settle { - from { - opacity: 0; - filter: blur(4px); - } - - to { - opacity: 1; - filter: blur(0); - } - -} - -@media (prefers-reduced-motion: reduce), (pointer: coarse) { - [data-pointer-card] { - --card-lift: 0px; - --card-scale: 1; - --pointer-glow-opacity: 0; - --pointer-rotate-x: 0deg; - --pointer-rotate-y: 0deg; - transform: none; - } - - [data-pointer-card]::before { - display: none; - } - - .natural-agent-preview, .natural-progress__master, .natural-result__master, .natural-result__sequences { - animation: none; - } - -} diff --git a/frontend/src/pages/workflow-editor/workflow-to-character.ts b/frontend/src/pages/workflow-editor/workflow-to-character.ts new file mode 100644 index 0000000..0c59feb --- /dev/null +++ b/frontend/src/pages/workflow-editor/workflow-to-character.ts @@ -0,0 +1,194 @@ +/** + * WorkflowRun → Character 桥接层。 + * 从工作流步骤中提取数据,组装为 Playtest 可消费的 Character 实体。 + */ +import type { + Character, + Outfit, + Action, + Frame, + ActionType, + CharacterTemplateCandidate, +} from '@/entities/character' +import type { WorkflowRun, WorkflowRevision, 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 +} + +function getStepOutput(revision: WorkflowRevision, type: string): unknown { + const step = getStep(revision, type) + return (step as any)?.output ?? null +} + +function getStepInput(revision: WorkflowRevision, type: string): unknown { + const step = getStep(revision, type) + return (step as any)?.input ?? null +} + +/** 从 character-template 步骤提取母版 URL */ +function extractCharacterTemplateUrl(revision: WorkflowRevision): string | null { + const output = getStepOutput(revision, 'character-template') 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 output = getStepOutput(revision, 'character-template') 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()}`, + })) +} + +/** 从 first-frame 步骤提取首帧 URL */ +function extractFirstFrameUrl(revision: WorkflowRevision, actionType: string): string | null { + const output = getStepOutput(revision, 'first-frame') as { url?: string; actionType?: string } | null + if (!output?.url) return null + // 如果有多个动作,按 actionType 区分 + if (output.actionType && output.actionType !== actionType) return null + return output.url +} + +/** 从 complete-animation 步骤提取帧列表 */ +function extractAnimationFrames(revision: WorkflowRevision, actionType: string): string[] { + const output = getStepOutput(revision, 'complete-animation') as { frames?: string[]; actionType?: string } | null + if (!output?.frames?.length) return [] + if (output.actionType && output.actionType !== actionType) return [] + return output.frames +} + +/** 从 action-setup 步骤提取选定的动作类型 */ +function extractSelectedActionType(revision: WorkflowRevision): ActionType { + const input = getStepInput(revision, 'action-setup') as { actionType?: string } | null + const raw = input?.actionType ?? 'walk' + const valid: ActionType[] = ['walk', 'idle', 'attack', 'jump', 'custom'] + return valid.includes(raw as ActionType) ? (raw as ActionType) : 'walk' +} + +/** 从 character-setup 步骤提取角色描述 */ +function extractDescription(revision: WorkflowRevision): string { + const input = getStepInput(revision, 'character-setup') as { description?: string } | null + return input?.description?.trim() || '未命名角色' +} + +/** + * 将 WorkflowRun 转换为 Character 实体。 + * 如果关键步骤未完成,返回 null。 + */ +export function workflowRunToCharacter(run: WorkflowRun): Character | null { + const revision = getRevision(run) + if (!revision) return null + + const templateStep = getStep(revision, 'character-template') + const candidateStep = getStep(revision, 'template-candidate') + const actionStep = getStep(revision, 'action-setup') + const keyframeStep = getStep(revision, 'first-frame') + const animationStep = getStep(revision, 'complete-animation') + + // 至少需要母版已生成 + if (!templateStep || templateStep.status !== 'passed') return null + + const characterTemplateUrl = extractCharacterTemplateUrl(revision) + const candidates = extractCandidates(revision) + const description = extractDescription(revision) + const actionType = extractSelectedActionType(revision) + const firstFrameUrl = extractFirstFrameUrl(revision, actionType) + const animationFrames = extractAnimationFrames(revision, actionType) + + // 构建帧列表 + const frames: Frame[] = [] + + // 如果有首帧,加入 + if (firstFrameUrl) { + frames.push({ + imageUrl: firstFrameUrl, + durationMs: 125, // 8 FPS default + rootMotion: null, + }) + } + + // 如果有动画帧,加入 + if (animationFrames.length > 0) { + for (const url of animationFrames) { + frames.push({ + imageUrl: url, + durationMs: 125, + rootMotion: null, + }) + } + } + + // 构建动作 + const actions: Action[] = [] + if (frames.length > 0) { + actions.push({ + id: `${run.id}-${actionType}`, + outfitId: `${run.id}-outfit`, + name: actionType === 'walk' ? '行走' : actionType === 'idle' ? '待机' : actionType, + kind: 'custom', + type: actionType, + fps: 8, + keyFrameIndex: firstFrameUrl ? 0 : 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 canExportToPlaytest(run: WorkflowRun): boolean { + const revision = getRevision(run) + if (!revision) return false + + const templateStep = getStep(revision, 'character-template') + const animationStep = getStep(revision, 'complete-animation') + + return ( + templateStep?.status === 'passed' && + animationStep?.status === 'passed' + ) +} + +/** + * 生成 Playtest 跳转 URL。 + */ +export function buildPlaytestUrl(character: Character): string { + const outfit = character.outfits[0] + if (!outfit) return '/playtest/demo' + + const firstAction = outfit.actions[0] + const actionParam = firstAction ? `?actionId=${firstAction.id}` : '' + return `/playtest/${encodeURIComponent(character.id)}/${encodeURIComponent(outfit.id)}${actionParam}` +} diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..1bec57d --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.10","results":[[":frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts",{"duration":11.368200000000002,"failed":false}],[":frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts",{"duration":15.456199999999967,"failed":false}],[":frontend/src/pages/playtest/workbench/playback/playback-state.test.ts",{"duration":7.5692000000000235,"failed":false}],[":frontend/src/pages/playtest/playtest-boundaries.test.ts",{"duration":43.97460000000001,"failed":false}],[":frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts",{"duration":4.972399999999993,"failed":false}],[":frontend/src/pages/playtest/testing/demo-character.test.ts",{"duration":6.655599999999993,"failed":false}],[":frontend/src/pages/playtest/workbench/audit/audit-session.test.ts",{"duration":4.1259000000000015,"failed":false}]]} \ No newline at end of file From c57451f26bce5dc58bb19dd27732daf6739b1012 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:51:52 +0800 Subject: [PATCH 49/49] refactor(frontend): consolidate workflow architecture Unify Quick Start and Workflow Editor around one controller, move transport and export behavior into stable entity and feature boundaries, and preserve complete animation results through review and publishing. Add real project and asset pages, recovery coverage, architecture guards, documentation, and repository-wide frontend formatting.\n\nCo-Authored-By: Codex --- frontend/API_CONTRACT.md | 21 +- frontend/ARCHITECTURE_GUARDRAILS.md | 65 +- frontend/MODULES.md | 31 + frontend/README.md | 9 +- frontend/package.json | 2 +- .../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/adapters/generation.ts | 121 --- frontend/src/app/adapters/index.ts | 15 - frontend/src/app/adapters/task.ts | 106 -- frontend/src/app/api-contract.test.ts | 85 ++ frontend/src/app/app.test.tsx | 9 + frontend/src/app/app.tsx | 106 +- frontend/src/architecture-boundaries.test.ts | 51 + .../src/entities/action-template/index.ts | 14 - .../character/api.ts} | 20 +- frontend/src/entities/generation/api.ts | 201 ++++ frontend/src/entities/generation/index.ts | 68 +- frontend/src/entities/index.ts | 17 +- .../media.ts => entities/media/api.ts} | 16 +- frontend/src/entities/media/index.ts | 8 + .../project.ts => entities/project/api.ts} | 43 +- frontend/src/entities/project/index.ts | 11 - frontend/src/entities/task/index.ts | 52 - frontend/src/entities/workflow-run/index.ts | 22 +- .../src/entities/workflow-run/store.test.ts | 63 +- frontend/src/entities/workflow-run/store.ts | 93 +- .../export-package}/asset-export.test.ts | 6 +- .../export-package}/asset-export.ts | 17 +- .../export-package}/export-panel.test.tsx | 2 +- .../export-package}/export-panel.tsx | 6 +- frontend/src/features/export-package/index.ts | 10 + frontend/src/features/export-package/model.ts | 32 + frontend/src/features/export/index.ts | 6 - frontend/src/features/generation/index.ts | 10 - frontend/src/features/publish/index.ts | 63 ++ .../features/publish/workflow-to-character.ts | 140 +++ frontend/src/features/review/index.ts | 36 +- .../action-generation-task.ts | 231 ++++ .../character-template-task.ts | 39 +- .../workflow-controller/controller.test.ts | 153 ++- .../workflow-controller/controller.ts | 129 ++- .../store-invariants.test.ts | 218 ++++ .../workflow-run.integration.test.ts | 12 +- .../workflow-state.test.ts | 28 +- .../workflow-controller/workflow-state.ts | 166 ++- frontend/src/pages/asset-library/index.tsx | 70 ++ frontend/src/pages/history/index.tsx | 70 +- frontend/src/pages/home/index.test.tsx | 2 +- frontend/src/pages/home/index.tsx | 2 +- frontend/src/pages/playtest/demo-page.tsx | 48 +- frontend/src/pages/playtest/index.tsx | 8 +- .../workbench/analysis/image-geometry.ts | 20 +- .../workbench/analysis/quality-policy.ts | 2 + .../playtest/workbench/animation-stage.tsx | 74 +- .../src/pages/playtest/workbench/index.tsx | 51 +- frontend/src/pages/project-create/index.tsx | 110 +- frontend/src/pages/project-detail/index.tsx | 58 +- frontend/src/pages/projects/index.tsx | 66 +- frontend/src/pages/quick-start/index.test.tsx | 21 +- frontend/src/pages/quick-start/index.tsx | 205 +++- .../src/pages/quick-start/service.test.ts | 307 ++++++ frontend/src/pages/quick-start/service.ts | 238 ++++- frontend/src/pages/workflow-editor/index.tsx | 384 +++---- .../src/pages/workflow-editor/node-canvas.ts | 96 +- .../pages/workflow-editor/project-setup.tsx | 94 -- frontend/src/pages/workflow-editor/service.ts | 93 +- frontend/src/pages/workflow-editor/state.ts | 147 --- frontend/src/pages/workflow-editor/types.ts | 57 - .../pages/workflow-editor/workflow-canvas.tsx | 251 ++--- .../pages/workflow-editor/workflow-editor.css | 993 +++++++++++++++--- .../workflow-editor/workflow-to-character.ts | 194 ---- frontend/src/shared/README.md | 3 +- .../adapters => shared/api}/http-client.ts | 27 +- frontend/src/shared/api/index.ts | 2 + 78 files changed, 5094 insertions(+), 1812 deletions(-) create mode 100644 frontend/MODULES.md 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 delete mode 100644 frontend/src/app/adapters/generation.ts delete mode 100644 frontend/src/app/adapters/index.ts delete mode 100644 frontend/src/app/adapters/task.ts create mode 100644 frontend/src/app/api-contract.test.ts create mode 100644 frontend/src/architecture-boundaries.test.ts delete mode 100644 frontend/src/entities/action-template/index.ts rename frontend/src/{app/adapters/character.ts => entities/character/api.ts} (91%) create mode 100644 frontend/src/entities/generation/api.ts rename frontend/src/{app/adapters/media.ts => entities/media/api.ts} (63%) rename frontend/src/{app/adapters/project.ts => entities/project/api.ts} (66%) delete mode 100644 frontend/src/entities/task/index.ts rename frontend/src/{pages/playtest/workbench/export => features/export-package}/asset-export.test.ts (97%) rename frontend/src/{pages/playtest/workbench/export => features/export-package}/asset-export.ts (97%) rename frontend/src/{pages/playtest/workbench/export => features/export-package}/export-panel.test.tsx (98%) rename frontend/src/{pages/playtest/workbench/export => features/export-package}/export-panel.tsx (97%) create mode 100644 frontend/src/features/export-package/index.ts create mode 100644 frontend/src/features/export-package/model.ts delete mode 100644 frontend/src/features/export/index.ts delete mode 100644 frontend/src/features/generation/index.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/store-invariants.test.ts create mode 100644 frontend/src/pages/asset-library/index.tsx create mode 100644 frontend/src/pages/quick-start/service.test.ts delete mode 100644 frontend/src/pages/workflow-editor/project-setup.tsx delete mode 100644 frontend/src/pages/workflow-editor/state.ts delete mode 100644 frontend/src/pages/workflow-editor/types.ts delete mode 100644 frontend/src/pages/workflow-editor/workflow-to-character.ts rename frontend/src/{app/adapters => shared/api}/http-client.ts (71%) create mode 100644 frontend/src/shared/api/index.ts diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 427b6eb..48b5e8e 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -22,18 +22,16 @@ 图片生成和动作生成只返回任务及结果,不自动修改 WorkflowRun 或角色资产。用户最终确认后,前端再通过角色更新接口保存角色图和完整动作数据。 ---- - -## 二、前端预期有、后端目前没有 +动作任务的 `frames[]` 会完整映射为 WorkflowRun 的 `complete_animation` 结果,保留顺序和 +`duration_ms`。审核前为满足动作生成接口的 `character_id/outfit_id` 要求,前端会创建一条 +尚无动作的角色草稿;只有审核通过后才把完整动作写回该角色。当前后端没有草稿/已发布状态, +因此资产库暂以“造型至少包含一个动作”作为已发布资产的显示条件。 -**这些接口仍需要确定由后端提供,还是改为前端本地能力。** +--- -| 前端接口 | 后端情况 | -|---|---| -| `ActionTemplateApis.listAvailable` | 没有 action template 模块 | -| `ProjectApis.update` | 没有 `PATCH /projects/{project_id}` | +## 二、前端不声明的后端缺失能力 -前端已按服务端现状去掉 `TaskApis.cancel`——后端没有取消能力,不声明前端用不到的接口。 +后端目前没有项目更新和生成任务取消接口,前端相应地不声明 `ProjectApis.update` 或生成取消方法。创建、查询和轮询统一由 `GenerationApis` 提供。 --- @@ -45,7 +43,7 @@ |---|---|---| | 角色列表 | `list_characters` 分页,返回 `(list, total)` | `listByProject` 无分页 | | 更新角色 | `update_character(character_id, **fields)` 部分更新 | `update(character)` 整棵树替换 | -| 等待任务完成 | 提供 `GET /generation/tasks/{task_id}` 轮询 | `TaskApis.subscribe`;适配器先立即回放当前快照,再继续轮询 | +| 等待任务完成 | 提供 `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` | @@ -61,7 +59,6 @@ ID 类型后端为 `int`、前端为 `string`,由前端转换层处理,不 | `delete_character` | 前端 `CharacterApis` 没有删除 | | `Character.description` | 后端存在实体上;前端只在创建入参里,创建完查不到 | | `Character.reference_image_url` | 后端存在实体上;前端 `Character` 类型没有这个字段 | -| `MediaService.upload` | 前端本次未提交上传模块 | --- @@ -105,12 +102,10 @@ frames[] → index / image_url / duration_ms ## 待确认 -- [ ] `ActionTemplateApis` 由后端提供还是前端内置 - [ ] 母版候选几张 - [ ] 参考图与角色图是一个字段还是两个 - [ ] `Character.description` 前端要不要跟着存 - [ ] `Action.kind` / `Action.keyFrameIndex` / `Frame.rootMotion` 是否进入最终资产 -- [ ] 上传模块何时提交 ## 已分工 diff --git a/frontend/ARCHITECTURE_GUARDRAILS.md b/frontend/ARCHITECTURE_GUARDRAILS.md index 819733c..ae61d68 100644 --- a/frontend/ARCHITECTURE_GUARDRAILS.md +++ b/frontend/ARCHITECTURE_GUARDRAILS.md @@ -1,52 +1,41 @@ # Frontend Architecture Guardrails -This document governs frontend changes as the product workflow evolves. It takes precedence over early skeleton references to an asset library or reusable assets. - -## Module Direction +## Code Layers ```text -app -> pages -> features -> workflow-controller / entities -> shared +app -> pages -> features -> entities -> shared + | + +-> workflow-controller -> entities ``` -Dependencies move only to the right. `workflow-controller` may use entity contracts; entities do not use the controller. `shared` is business-agnostic and imports none of the other layers. Use each module's public entrypoint rather than a deep implementation import. - -- `app` starts the application and owns routing, global layout, and app-wide providers. -- `pages` translate route state into a product scene and compose features. They do not implement domain transitions or call transport code directly. -- `features` own user-facing capabilities and may prepare controller inputs or call entity APIs. They do not own a second workflow state machine. -- `workflow-controller` owns `WorkflowRun` progression, revision lineage, restart, interruption, and application of async results. -- `entities` own business types and stable frontend API contracts. `shared` contains only generic UI, hooks, utilities, and validated runtime configuration. - -## Backend Boundary - -Backend URLs, DTO envelopes, media handling, authentication, events, and error formats remain unstable until explicitly frozen. Keep their translation behind an entity API adapter: +- `app` 只组装真实 API、共享 Controller、路由和全局外壳。 +- `pages` 负责一个路由场景,不定义后端 DTO 或第二套业务状态。 +- `features` 负责可复用的用户行为,例如审核、发布和下载包。 +- `features` 不依赖 `pages` 内部类型;需要共享的只读模型由 Feature 自己声明结构边界。 +- `workflow-controller` 是创作运行、步骤推进、Revision 和异步结果写回的唯一入口。 +- `entities` 保存领域类型、实体 API 契约及其 DTO 转换。 +- `shared` 只保存通用 HTTP、分页、UI、Hook 和工具,不能理解 Windup 业务词汇。 -- Pages, features, and the controller depend on frontend entity contracts, never backend DTOs or direct `fetch` calls. -- Adapters validate and map untrusted transport data to frontend types. `unknown` results must be narrowed before business use. -- Do not promote a temporary endpoint shape into shared types, page props, or workflow rules. Change the adapter contract deliberately, with its callers and tests, when the backend contract is frozen. +## State Ownership -## Workflow Invariants +`WorkflowRun` 和 `WorkflowStep` 是 Quick Start 与 Workflow Editor 共用的唯一流程状态。画布节点只由步骤投影而来;连线和 URL 中的 `stepId` 只控制显示与聚焦,不能决定业务是否可推进。刷新恢复时,角色图与完整动作都通过同一 `GenerationApis.get/subscribe` 继续查询;完整动作结果必须保留全部帧,不能降级成首帧。 -`WorkflowRun` is a project-owned record shared by Quick Start and Workflow Editor. A run has one current revision; historical revisions are read-only. Async work is associated with the revision that started it, and a result for an obsolete revision must be ignored. +## Product Boundaries -The product currently presents five ordered workflow stages. Treat that order as per-run configuration, not a permanently hard-coded stage count or array index: future products may configure a different sequence. Supporting model step names may be more granular, but the visible stage order must remain explicit. +- `AssetLibrary` 展示后端已经保存的 `Character -> Outfit -> Action -> Frame` 资产树。 +- `History` 展示 `WorkflowRun` 的执行与版本记录。两者不能互相改名或合并。 +- `Review` 在创作流程中做通过或回推决定,会改变 WorkflowRun。 +- `Playtest` 只检查已发布资产,问题记录不会反向修改工作流或角色数据。 +- `Publish` 把审核通过的结果写入资产并进入 Playtest。 +- `ExportPackage` 只在 Playtest 中下载 Sprite Sheet 和清单文件,不等于发布。 -Restarting from a selected stage creates a new descendant revision. The new lineage preserves inputs through that selected stage only; it invalidates or removes all downstream execution, outputs, and completion evidence. The source revision stays intact for history, but its downstream results are not valid inputs to the new lineage. - -Only successful system QC marks a version complete. Playtest is downstream, read-only verification and cannot change a run or revision to completed. Export is a separate post-completion action and separately records that a completed version was exported; it does not redefine completion. - -## Product Surface Boundaries - -- **Quick Start** accepts intent and references, drives the same `WorkflowRun` automatically, and hides stages, revisions, restart mechanics, and other workflow internals. -- **Workflow Editor** exposes the configured stage sequence and is the only surface for deliberate step-level editing, revision history, and restart. -- **Playtest** consumes a selected completed version as a read-only inspection target. It may save an independent inspection conclusion, but never changes characters, frames, workflow data, QC, completion, or export state. +## Backend Boundary -Completed versions are project-scoped history, retained for traceability and selectable for import into Playtest. They are not a reusable asset library: do not add cross-project browsing, copying, sharing, or reuse contracts/UI without an explicit product decision. +页面和 Feature 不直接 `fetch`。通用传输逻辑在 `shared/api`;项目、角色、生成和媒体 DTO 分别在对应 `entities/*/api.ts` 映射。后端没有的能力不得用本地假成功、假 ID 或“跳过步骤”代替。 -## Pre-change Review +## Review Checklist -- Is the code in the lowest layer that can own it, with dependencies following the direction above? -- Does every backend interaction remain behind a typed entity adapter rather than leaking provisional transport data? -- Does workflow behavior preserve one current revision, ignore stale async results, and invalidate downstream work after restart? -- Does completion come only from system QC, with Playtest and export kept separate? -- Does the UI preserve Quick Start, Workflow Editor, and Playtest boundaries and keep completed-version history inside its project? -- Do focused tests cover the changed contract, especially restart lineage, stale results, status transitions, and read-only Playtest behavior? +- 是否复用了同一个 WorkflowController,而非在页面创建状态机? +- 是否区分资产库、历史、审核、预览、发布和下载? +- 是否由实体 API 隔离了后端字段与前端领域类型? +- 是否覆盖失败、刷新恢复、重复提交和终态转换? diff --git a/frontend/MODULES.md b/frontend/MODULES.md new file mode 100644 index 0000000..d18ad36 --- /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 46beaba..002bc73 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -23,11 +23,10 @@ CI 按上面顺序全跑一遍。 ## 结构 -模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 +六个业务模块见 `MODULES.md`,代码依赖规则见 `ARCHITECTURE_GUARDRAILS.md`。 -当前已实现纯前端 `WorkflowRun` 存储,以及 -`角色资料 → 角色图生成 → 候选选择` 的首个 Controller 纵切。Playtest 已提供可交互的 -只读核验工作台和 `/playtest/demo` 演示入口;Quick Start、Workflow Editor 等其余页面仍是 -占位外壳。后五步和真实 `XxxApis` 实现按模块拆成后续 PR。 +当前 Project、Character、Generation、Media 已接真实后端适配器。Quick Start 与 Workflow +Editor 共享同一套 `WorkflowRun` 和 Controller;项目页、资产库、历史记录与 Playtest 分别 +承担不同产品职责。Playtest 提供只读检查与独立下载包能力。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 diff --git a/frontend/package.json b/frontend/package.json index 0a845f7..47bcbcc 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", diff --git a/frontend/src/app/__fixtures__/character-25.json b/frontend/src/app/__fixtures__/character-25.json new file mode 100644 index 0000000..7dd37ae --- /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 0000000..2ae3ebc --- /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 0000000..f351cb5 --- /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 0000000..4e96831 --- /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 0000000..c4cc816 --- /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/adapters/generation.ts b/frontend/src/app/adapters/generation.ts deleted file mode 100644 index 4954b7f..0000000 --- a/frontend/src/app/adapters/generation.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { - Generation, - GenerationApis, - GenerationInput, - GenerationType, -} from '@/entities' - -import { post } from './http-client' - -/* ─── 后端 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', -} - -function toGeneration( - raw: BackendGenerationTask, -): Generation { - return { - id: String(raw.id), - projectId: String(raw.project_id), - type: raw.task_type as T, - status: STATUS_MAP[raw.status] ?? 'pending', - result: raw.result as Generation['result'], - error: raw.error_message, - } -} - -/* ─── 输入 → 后端请求体 ─── */ - -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, - num_images: 1, - } - } - - 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: 16, - } -} - -/* ─── 适配器 ─── */ - -const GENERATION_ENDPOINTS: Record = { - character_template: '/generation/image', - first_frame: '/generation/action', - complete_animation: '/generation/action', -} - -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) - }, - - async get( - projectId: string, - id: string, - ): Promise { - // 复用 TaskApis 的查询接口 - const { createTaskApis } = await import('./task') - const taskApis = createTaskApis() - const task = await taskApis.get(projectId, id) - return { - id: task.id, - projectId, - type: task.type as GenerationType, - status: task.status, - result: task.result as Generation['result'], - error: task.error, - } - }, - } -} diff --git a/frontend/src/app/adapters/index.ts b/frontend/src/app/adapters/index.ts deleted file mode 100644 index c6d7c1c..0000000 --- a/frontend/src/app/adapters/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 后端 API 适配器统一入口。 - * - * 页面按需引入所需适配器,不通过全局依赖注入: - * import { createProjectApis } from '@/app/adapters' - * const projectApis = createProjectApis() - */ - -export { createProjectApis } from './project' -export { createCharacterApis } from './character' -export { createTaskApis } from './task' -export { createGenerationApis } from './generation' -export { createMediaApis } from './media' -export type { MediaApis, MediaCategory } from './media' -export { ApiError } from './http-client' diff --git a/frontend/src/app/adapters/task.ts b/frontend/src/app/adapters/task.ts deleted file mode 100644 index 48ed94d..0000000 --- a/frontend/src/app/adapters/task.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from '@/entities' - -import { get } from './http-client' - -/* ─── 后端 DTO ─── */ - -interface BackendTask { - id: number - user_id: number - project_id: number - task_type: string - status: string - input_payload: Record - result: unknown - error_message: string | null -} - -/* ─── 映射 ─── */ - -const TASK_TYPE_MAP: Record = { - character_image: 'character_template', - character_template: 'character_template', - character_action: 'complete_animation', - first_frame: 'first_frame', - complete_animation: 'complete_animation', -} - -const TASK_STATUS_MAP: Record = { - pending: 'pending', - running: 'running', - completed: 'completed', - failed: 'failed', -} - -function toTask(raw: BackendTask): Task { - return { - id: String(raw.id), - type: TASK_TYPE_MAP[raw.task_type] ?? 'character_template', - status: TASK_STATUS_MAP[raw.status] ?? 'pending', - error: raw.error_message, - result: raw.result, - } -} - -function toTaskEvent(raw: BackendTask): TaskEvent { - return { - taskId: String(raw.id), - type: TASK_TYPE_MAP[raw.task_type] ?? 'character_template', - status: TASK_STATUS_MAP[raw.status] ?? 'pending', - error: raw.error_message, - result: raw.result, - } -} - -/* ─── 适配器 ─── */ - -const POLL_INTERVAL_MS = 2000 - -export function createTaskApis(): TaskApis { - return { - async get(projectId: string, taskId: string): Promise { - const raw = await get( - `/generation/tasks/${taskId}?project_id=${encodeURIComponent(projectId)}`, - ) - return toTask(raw) - }, - - subscribe( - projectId: string, - taskId: string, - onEvent: (event: TaskEvent) => void, - ): () => void { - let active = true - let timer: ReturnType | null = null - - const poll = async () => { - if (!active) return - try { - const raw = await get( - `/generation/tasks/${taskId}?project_id=${encodeURIComponent(projectId)}`, - ) - if (!active) return - onEvent(toTaskEvent(raw)) - if (raw.status === 'completed' || raw.status === 'failed') { - return // 终态,停止轮询 - } - timer = setTimeout(poll, POLL_INTERVAL_MS) - } catch { - // 请求失败时继续重试 - if (active) timer = setTimeout(poll, POLL_INTERVAL_MS) - } - } - - // 立即发送一次当前状态 - void poll() - - return () => { - active = false - if (timer !== null) { - clearTimeout(timer) - timer = null - } - } - }, - } -} diff --git a/frontend/src/app/api-contract.test.ts b/frontend/src/app/api-contract.test.ts new file mode 100644 index 0000000..b3a3b7f --- /dev/null +++ b/frontend/src/app/api-contract.test.ts @@ -0,0 +1,85 @@ +/** + * 前后端契约测试:用真实后端响应快照(__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}`) + }), + 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.test.tsx b/frontend/src/app/app.test.tsx index 988fb46..bc207e1 100644 --- a/frontend/src/app/app.test.tsx +++ b/frontend/src/app/app.test.tsx @@ -25,4 +25,13 @@ describe('App', () => { expect(screen.getByRole('heading', { name: '历史记录' })).toBeTruthy() }) + + it('keeps the asset library separate from workflow history', () => { + window.history.replaceState({}, '', '/projects/project-1/assets') + + render() + + expect(screen.getByRole('heading', { name: '资产库' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: '历史记录' })).toBeNull() + }) }) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 5aba96f..1e22871 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,6 +1,14 @@ import { useMemo } from 'react' import { BrowserRouter, Route, Routes } from 'react-router' +import { + createCharacterApis, + createGenerationApis, + createProjectApis, + createWorkflowRunStore, +} from '@/entities' +import { createWorkflowController } from '@/features/workflow-controller' +import { AssetLibraryPage } from '@/pages/asset-library' import { HomePage } from '@/pages/home' import { HistoryPage } from '@/pages/history' import { NotFoundPage } from '@/pages/not-found' @@ -12,23 +20,59 @@ import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' import { AppShell } from './layout' -import { - createProjectApis, - createGenerationApis, - createTaskApis, -} from './adapters' -import { createRealQuickStartService } from '@/pages/quick-start/service' +import { createAutoPrepareProject, createQuickStartService } from '@/pages/quick-start/service' +import { createWorkflowEditorService } from '@/pages/workflow-editor/service' + +function PlaytestFromBackend() { + const apis = useMemo(() => ({ characters: createCharacterApis() }), []) + return +} /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 */ export function App() { - const quickStartService = useMemo(() => { + const services = useMemo(() => { const projectApis = createProjectApis() + const characterApis = createCharacterApis() const generationApis = createGenerationApis() - const taskApis = createTaskApis() - return createRealQuickStartService({ projectApis, generationApis, taskApis }) + const store = createWorkflowRunStore() + const controller = createWorkflowController({ store, generationApis }) + const quickStart = createQuickStartService({ + controller, + prepareProject: createAutoPrepareProject(projectApis), + characterApis, + generationApis, + }) + const workflowEditor = createWorkflowEditorService({ + controller, + confirmCandidate: (runId, selectedImageUrl) => + quickStart.confirmCandidate(runId, selectedImageUrl), + 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 } + }, + }) + return { projectApis, characterApis, quickStart, workflowEditor, store } }, []) return ( @@ -36,18 +80,40 @@ export function App() { } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } + /> + } /> + } /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } + /> } /> - } /> + } /> } /> diff --git a/frontend/src/architecture-boundaries.test.ts b/frontend/src/architecture-boundaries.test.ts new file mode 100644 index 0000000..6551173 --- /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 2dafa39..0000000 --- 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/app/adapters/character.ts b/frontend/src/entities/character/api.ts similarity index 91% rename from frontend/src/app/adapters/character.ts rename to frontend/src/entities/character/api.ts index bbe720a..23eec25 100644 --- a/frontend/src/app/adapters/character.ts +++ b/frontend/src/entities/character/api.ts @@ -6,9 +6,9 @@ import type { CreateCharacterInput, Frame, Outfit, -} from '@/entities' +} from '.' -import { del, get, patch, post } from './http-client' +import { get, patch, post } from '@/shared/api' /* ─── 后端 DTO ─── */ @@ -75,9 +75,7 @@ function toAction(raw: BackendAction, outfitId: string): Action { type: toActionType(raw.type), fps: raw.fps, keyFrameIndex: null, // 后端不提供关键帧索引 - frames: raw.frames - .sort((a, b) => a.index - b.index) - .map(toFrame), + frames: raw.frames.sort((a, b) => a.index - b.index).map(toFrame), } } @@ -106,13 +104,6 @@ function toCharacter(raw: BackendCharacter): Character { /* ─── 适配器 ─── */ -interface BackendListResponse { - data: BackendCharacter[] - total: number - page: number - page_size: number -} - export function createCharacterApis(): CharacterApis { return { async get(id: string): Promise { @@ -121,10 +112,11 @@ export function createCharacterApis(): CharacterApis { }, async listByProject(projectId: string): Promise { - const raw = await get( + // http-client 已解包 ApiEnvelope,data 字段就是角色数组本身 + const raw = await get( `/characters?project_id=${encodeURIComponent(projectId)}`, ) - return raw.data.map(toCharacter) + return raw.map(toCharacter) }, async create(input: CreateCharacterInput): Promise { diff --git a/frontend/src/entities/generation/api.ts b/frontend/src/entities/generation/api.ts new file mode 100644 index 0000000..3507c2f --- /dev/null +++ b/frontend/src/entities/generation/api.ts @@ -0,0 +1,201 @@ +import type { + Generation, + GenerationApis, + GenerationEvent, + GenerationInput, + GenerationType, +} from '.' + +import { get, post } from '@/shared/api' + +/* ─── 后端 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 toGenerationEvent(raw: BackendGenerationTask): GenerationEvent { + const generation = toGeneration(raw) + return { + taskId: generation.id, + type: generation.type, + status: generation.status, + result: generation.result, + error: generation.error, + } +} + +/* ─── 输入 → 后端请求体 ─── */ + +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: 16, + } +} + +/* ─── 适配器 ─── */ + +const GENERATION_ENDPOINTS: Record = { + character_template: '/generation/image', + first_frame: '/generation/action', + complete_animation: '/generation/action', +} + +const POLL_INTERVAL_MS = 2000 + +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) { + let active = true + let timer: ReturnType | null = null + + const poll = async () => { + if (!active) return + try { + const raw = await get( + `/generation/tasks/${id}?project_id=${encodeURIComponent(projectId)}`, + ) + if (!active) return + onEvent(toGenerationEvent(raw)) + if (raw.status === 'completed' || raw.status === 'failed') return + } catch { + // 短暂网络失败时保留恢复能力,下一轮继续读取同一 Generation。 + } + if (active) timer = setTimeout(poll, POLL_INTERVAL_MS) + } + + void poll() + return () => { + active = false + if (timer !== null) clearTimeout(timer) + } + }, + } +} diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index a504da3..e7cca6d 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -1,6 +1,5 @@ import type { ActionType } from '../character' import type { MediaReference } from '../media' -import type { Task, TaskStatus } from '../task' /** * Generation 是业务数据,不是「调用图片生成能力」。 @@ -10,6 +9,9 @@ import type { Task, TaskStatus } from '../task' /** 生成对应的三个前端可见异步步骤。 */ export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' +/** 后端单次生成任务的生命周期。 */ +export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed' + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ @@ -21,6 +23,10 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */ + spriteWidth: number + /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */ + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ @@ -61,7 +67,7 @@ export interface CharacterTemplateGenerationResult { } /** - * Task.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 + * Generation.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 * * 兼容后端两种返回格式: * - 旧版单图:`{ type, image_url: "..." }` @@ -70,7 +76,10 @@ export interface CharacterTemplateGenerationResult { export function parseCharacterTemplateGenerationResult( value: unknown, ): CharacterTemplateGenerationResult | null { - if (!isRecord(value) || value.type !== 'character_template') { + if ( + !isRecord(value) || + (value.type !== 'character_template' && value.type !== 'character_image') + ) { return null } @@ -104,10 +113,37 @@ export interface FirstFrameGenerationResult { 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 = @@ -127,24 +163,42 @@ export type GenerationResultFor = * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 */ export interface Generation { - /** 创建接口返回的后端 Task ID;后续查询与订阅必须把它交给 TaskApis。 */ - id: Task['id'] + /** 创建接口返回的后端任务 ID。 */ + id: string projectId: string /** 与创建时的输入判别字段保持同一字面量类型。 */ type: TType - status: TaskStatus + status: GenerationTaskStatus /** 完成前为 null;完成后形状由 type 决定。 */ result: GenerationResult | null /** status 为 failed 时有值。 */ error: string | null } +/** 后端任务状态变化映射成同一份 Generation 快照。 */ +export interface GenerationEvent extends Omit< + Generation, + 'id' | 'projectId' +> { + /** 字段名对应后端事件中的 task_id,但语义上仍是 Generation.id。 */ + taskId: Generation['id'] +} + /** Generation 对应的一组后端接口。 */ export interface GenerationApis { /** 创建一次生成任务。 */ create(input: T): Promise> /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ get(projectId: Generation['projectId'], id: Generation['id']): Promise + /** + * 订阅任务状态。当前后端没有 SSE 时,实现可以封装轮询;调用方不感知传输方式。 + * 返回取消订阅函数。 + */ + subscribe( + projectId: Generation['projectId'], + id: Generation['id'], + onEvent: (event: GenerationEvent) => void, + ): () => void } function isRecord(value: unknown): value is Record { diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 33b1ff8..e903dee 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -5,13 +5,13 @@ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { createProjectApis } from './project/api' export type { CharacterPerspective, CreateProjectInput, DirectionalMovement, Project, ProjectApis, - UpdateProjectInput, } from './project' /* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ @@ -29,12 +29,11 @@ export type { FrameRootMotion, Outfit, } from './character' - -/* 动作模板 —— 能跨角色复用的配方 */ -export type { ActionTemplate, ActionTemplateApis } from './action-template' +export { createCharacterApis } from './character/api' /* 生成 —— 业务数据,不是「调用生成能力」 */ export { parseCharacterTemplateGenerationResult } from './generation' +export { createGenerationApis } from './generation/api' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -42,20 +41,21 @@ export type { CompleteAnimationGenerationResult, FirstFrameGenerationInput, FirstFrameGenerationResult, + GeneratedAnimationFrame, GeneratedImage, Generation, GenerationApis, + GenerationEvent, GenerationInput, GenerationResult, GenerationResultFor, + GenerationTaskStatus, GenerationType, } from './generation' /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ -export type { MediaReference } from './media' - -/* 后端异步任务 —— 与工作流节点是两回事 */ -export type { Task, TaskApis, TaskEvent, TaskStatus, TaskType } from './task' +export { createMediaApis } from './media/api' +export type { MediaApis, MediaCategory, MediaReference } from './media' /* 工作流 —— 节点与运行状态都由前端管理 */ export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' @@ -63,6 +63,7 @@ export type { CharacterSetupStepInput, CharacterSetupWorkflowStep, CharacterTemplateWorkflowStep, + ActionGenerationWorkflowStep, CreateWorkflowRunStoreOptions, CreateWorkflowRunInput, ExportStatus, diff --git a/frontend/src/app/adapters/media.ts b/frontend/src/entities/media/api.ts similarity index 63% rename from frontend/src/app/adapters/media.ts rename to frontend/src/entities/media/api.ts index 5fa955d..a7624f1 100644 --- a/frontend/src/app/adapters/media.ts +++ b/frontend/src/entities/media/api.ts @@ -1,6 +1,6 @@ -import type { MediaReference } from '@/entities' +import type { MediaApis, MediaCategory, MediaReference } from '.' -import { upload as uploadRequest } from './http-client' +import { upload as uploadRequest } from '@/shared/api' /* ─── 后端 DTO ─── */ @@ -12,18 +12,6 @@ interface BackendMediaUpload { size: number } -/* ─── 前端接口 ─── */ - -export type MediaCategory = - | 'reference-image' - | 'outfit-preview' - | 'action-frame' - | 'general' - -export interface MediaApis { - upload(file: File, category?: MediaCategory): Promise -} - /* ─── 适配器 ─── */ export function createMediaApis(): MediaApis { diff --git a/frontend/src/entities/media/index.ts b/frontend/src/entities/media/index.ts index 347e626..cd8f854 100644 --- a/frontend/src/entities/media/index.ts +++ b/frontend/src/entities/media/index.ts @@ -7,3 +7,11 @@ declare const mediaReferenceBrand: unique symbol export type MediaReference = string & { readonly [mediaReferenceBrand]: 'MediaReference' } + +/** 上传媒体时的业务用途;后端据此选择存储目录和校验规则。 */ +export type MediaCategory = 'reference-image' | 'outfit-preview' | 'action-frame' | 'general' + +/** 媒体实体对应的后端能力。 */ +export interface MediaApis { + upload(file: File, category?: MediaCategory): Promise +} diff --git a/frontend/src/app/adapters/project.ts b/frontend/src/entities/project/api.ts similarity index 66% rename from frontend/src/app/adapters/project.ts rename to frontend/src/entities/project/api.ts index fc2b5ce..262a044 100644 --- a/frontend/src/app/adapters/project.ts +++ b/frontend/src/entities/project/api.ts @@ -1,7 +1,7 @@ -import type { CreateProjectInput, Project, ProjectApis, UpdateProjectInput } from '@/entities' +import type { CreateProjectInput, Project, ProjectApis } from '.' import type { Paged, PageQuery } from '@/shared/pagination' -import { del, get, patch, post } from './http-client' +import { del, get, post } from '@/shared/api' /* ─── 后端 DTO ─── */ @@ -74,31 +74,8 @@ function toCreatePayload(input: CreateProjectInput) { } } -function toUpdatePayload(input: UpdateProjectInput) { - const payload: Record = {} - if (input.name !== undefined) payload.project_name = input.name - if (input.perspective !== undefined) - payload.character_perspective = PERSPECTIVE_REVERSE[input.perspective] - if (input.directionalMovement !== undefined) - payload.directional_movement = MOVEMENT_REVERSE[input.directionalMovement] - if (input.spriteSize !== undefined) { - payload.sprite_width = input.spriteSize.width - payload.sprite_height = input.spriteSize.height - } - if (input.gameStyle !== undefined) payload.game_style = input.gameStyle - if (input.sampleImageUrl !== undefined) payload.sprite_sample_url = input.sampleImageUrl - return payload -} - /* ─── 适配器 ─── */ -interface BackendListResponse { - data: BackendProject[] - total: number - page: number - page_size: number -} - export function createProjectApis(): ProjectApis { return { async list(query?: PageQuery): Promise> { @@ -106,12 +83,13 @@ export function createProjectApis(): ProjectApis { if (query?.page) params.set('page', String(query.page)) if (query?.pageSize) params.set('page_size', String(query.pageSize)) const qs = params.toString() - const raw = await get(`/projects${qs ? `?${qs}` : ''}`) + // http-client 已解包 ApiEnvelope,data 字段就是项目数组本身 + const raw = await get(`/projects${qs ? `?${qs}` : ''}`) return { - items: raw.data.map(toProject), - total: raw.total, - page: raw.page, - pageSize: raw.page_size, + items: raw.map(toProject), + total: raw.length, + page: query?.page ?? 1, + pageSize: query?.pageSize ?? raw.length, } }, @@ -125,11 +103,6 @@ export function createProjectApis(): ProjectApis { return toProject(raw) }, - async update(id: string, input: UpdateProjectInput): Promise { - const raw = await patch(`/projects/${id}`, toUpdatePayload(input)) - return toProject(raw) - }, - async remove(id: string): Promise { await del(`/projects/${id}`) }, diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 4c71164..07c0c17 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -35,16 +35,6 @@ export interface CreateProjectInput { sampleImageUrl?: string | null } -/** 更新项目设置的入参;未提供的字段保持不变。 */ -export interface UpdateProjectInput { - name?: string - perspective?: CharacterPerspective - directionalMovement?: DirectionalMovement - spriteSize?: { width: number; height: number } - gameStyle?: string | null - sampleImageUrl?: string | null -} - /** 前端使用的游戏视角枚举;后端映射尚未冻结。 */ export type CharacterPerspective = 'side' | 'top-down' | 'isometric' @@ -77,6 +67,5 @@ export interface ProjectApis { list(query?: PageQuery): Promise> get(id: Project['id']): Promise create(input: CreateProjectInput): Promise - update(id: Project['id'], input: UpdateProjectInput): Promise remove(id: Project['id']): Promise } diff --git a/frontend/src/entities/task/index.ts b/frontend/src/entities/task/index.ts deleted file mode 100644 index 37e0950..0000000 --- a/frontend/src/entities/task/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 异步任务实体,与 WorkflowRun 的页面节点是两回事。 - * - * 任务粒度 = 前端可见的一个异步步骤,而不是内部某次模型调用。 - * 任务生命周期不等于工作流节点状态,两者只由 WorkflowStep.taskId 单向关联: - * 步骤记得自己发起过哪个任务,任务不认识步骤。 - */ - -/** 后端异步任务状态;pending 表示已提交但尚未执行。 */ -export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' - -/** - * MS2 前端需要展示和恢复的三类异步步骤。 - * 完整动画内部可包含视频生成、截帧和多次图像处理,但对前端仍是一个 Task。 - */ -export type TaskType = 'character_template' | 'first_frame' | 'complete_animation' - -/** - * 创建、查询和断线恢复都使用的完整任务快照。 - * TType 在调用边界已知时保留精确任务类型;按 ID 恢复时使用默认值,等待运行时解析后再收窄。 - */ -export interface Task { - /** 后端生成的任务 ID,是查询状态和订阅事件的唯一标识。 */ - id: string - /** 后端 task_type 对应的领域判别字段,不接受任意字符串。 */ - type: TType - /** 后端任务当前状态;它不会直接改变工作流节点状态。 */ - status: TaskStatus - /** 失败原因,status 为 failed 时有值。completed 不表示工作流节点已通过。 */ - error: string | null - /** 任务产出;契约冻结前保持 unknown,避免调用方依赖猜测结构。 */ - result: unknown -} - -/** 每条事件携带同类型 Task 的完整状态,taskId 对应 Task.id。 */ -export interface TaskEvent extends Omit, 'id'> { - taskId: Task['id'] -} - -/** Task 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */ -export interface TaskApis { - /** - * 按所属项目和后端 Task ID 读取最新快照。 - * projectId 不能从 taskId 推导;后端查询接口要求两者同时传入。 - */ - get(projectId: string, taskId: Task['id']): Promise - /** - * 订阅后必须立即发送一次最新完整快照,随后再发送状态变化;任务已经终止也必须发送。 - * 该语义关闭 get/create 与开始监听之间的终态竞态。 - */ - subscribe(projectId: string, taskId: Task['id'], onEvent: (event: TaskEvent) => void): () => void -} diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index f1a3a39..235e57a 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,9 +1,11 @@ import type { + Generation, CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, } from '../generation' import type { MediaReference } from '../media' -import type { Task } from '../task' import { EXPORT_STATUSES, GENERATION_STATUSES, @@ -45,7 +47,7 @@ export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] /** * 整次流程的汇总状态。 * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端 Task 是否真正停止是独立问题;当前纵切不提供重启操作。 + * 后端 Generation 是否真正停止是独立问题;前端中断只停止自动推进与订阅。 */ export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] @@ -65,7 +67,7 @@ interface WorkflowStepBase { * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 * 任务本身不认识步骤,反向关联不存在。 */ - taskId: Task['id'] | null + taskId: Generation['id'] | null /** * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 * 它非 null 而 taskId 为 null 时不能重复提交;若页面在这个窗口刷新, @@ -97,11 +99,20 @@ export interface CharacterTemplateWorkflowStep extends WorkflowStepBase { output: CharacterTemplateGenerationResult | null } -type RemainingWorkflowStepType = Exclude +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 } @@ -114,6 +125,7 @@ interface RemainingWorkflowStep extends WorkflowStepBase { export type WorkflowStep = | CharacterSetupWorkflowStep | CharacterTemplateWorkflowStep + | ActionGenerationWorkflowStep | RemainingWorkflowStep /** diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts index 3c8677a..91e4c78 100644 --- a/frontend/src/entities/workflow-run/store.test.ts +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -158,6 +158,23 @@ function createRestartedRun(): WorkflowRun { } 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 }) @@ -192,6 +209,50 @@ describe('createWorkflowRunStore', () => { 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( @@ -239,7 +300,7 @@ describe('createWorkflowRunStore', () => { it.each([ ['invalid JSON', '{'], - ['unknown version', JSON.stringify({ version: 4, runs: [createRun()] })], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], ['invalid payload', JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: {} })], [ 'invalid run', diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts index b5d7f05..b85e124 100644 --- a/frontend/src/entities/workflow-run/store.ts +++ b/frontend/src/entities/workflow-run/store.ts @@ -1,5 +1,8 @@ import type { WorkflowRun } from './index' -import { parseCharacterTemplateGenerationResult } from '../generation' +import { + parseCharacterTemplateGenerationResult, + parseCompleteAnimationGenerationResult, +} from '../generation' import { EXPORT_STATUSES, GENERATION_STATUSES, @@ -12,9 +15,10 @@ import { } from './constants' export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' -export const WORKFLOW_RUN_STORAGE_VERSION = 3 +export const WORKFLOW_RUN_STORAGE_VERSION = 4 type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void interface WorkflowRunStorage { getItem(key: string): string | null @@ -23,8 +27,10 @@ interface WorkflowRunStorage { 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 { @@ -98,6 +104,20 @@ function isWorkflowStep(value: unknown): boolean { (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 } @@ -171,7 +191,12 @@ function isWorkflowRun(value: unknown): value is WorkflowRun { const submissionId = step.submissionId if (taskId !== null && submissionId !== null) return false if (taskId === null && submissionId === null) return true - return step.type === 'character-template' && step.status === 'active' + // 只有 character-template 与 action-generation 允许在 active 步骤上 + // 持有任务 ID(角色图 / 动作生成任务,刷新后可恢复轮询) + return ( + (step.type === 'character-template' || step.type === 'action-generation') && + step.status === 'active' + ) }), ) } @@ -229,11 +254,44 @@ function migrateVersionOneRun(value: unknown): WorkflowRun | null { } const migrated = { ...value, revisions } - return isWorkflowRun(migrated) ? migrated : null + return migrateVersionThreeRun(migrated) } function migrateVersionTwoRun(value: unknown): WorkflowRun | null { - return isWorkflowRun(value) ? value : 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 { @@ -339,6 +397,13 @@ function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { .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 [] @@ -365,6 +430,7 @@ export function createWorkflowRunStore( 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) { @@ -372,6 +438,10 @@ export function createWorkflowRunStore( 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) @@ -394,6 +464,14 @@ export function createWorkflowRunStore( // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 } } + const snapshot = [...runs.values()].map((run) => structuredClone(run)) + for (const listener of listListeners) { + try { + listener(snapshot.map((run) => structuredClone(run))) + } catch { + // 一个列表订阅方失败不能阻断其他页面刷新。 + } + } }, subscribe(runId, listener) { @@ -406,5 +484,10 @@ export function createWorkflowRunStore( if (runListeners.size === 0) listeners.delete(runId) } }, + + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, } } diff --git a/frontend/src/pages/playtest/workbench/export/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts similarity index 97% rename from frontend/src/pages/playtest/workbench/export/asset-export.test.ts rename to frontend/src/features/export-package/asset-export.test.ts index 6b05507..d9cfeb8 100644 --- a/frontend/src/pages/playtest/workbench/export/asset-export.test.ts +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -1,7 +1,11 @@ /** @vitest-environment jsdom */ import { describe, expect, it, vi } from 'vitest' -import type { PlaytestPreviewModel, PreviewAction, PreviewFrame } from '../model/types' +import type { + ExportAction as PreviewAction, + ExportFrame as PreviewFrame, + ExportPackageModel as PlaytestPreviewModel, +} from './model' import { createAssetExportPlan, exportGameAssets, type AssetExportRuntime } from './asset-export' function frame(index: number): PreviewFrame { diff --git a/frontend/src/pages/playtest/workbench/export/asset-export.ts b/frontend/src/features/export-package/asset-export.ts similarity index 97% rename from frontend/src/pages/playtest/workbench/export/asset-export.ts rename to frontend/src/features/export-package/asset-export.ts index a270713..4310c6c 100644 --- a/frontend/src/pages/playtest/workbench/export/asset-export.ts +++ b/frontend/src/features/export-package/asset-export.ts @@ -1,9 +1,4 @@ -import type { - PlaytestPreviewModel, - PreviewAction, - PreviewFrame, - PreviewSequence, -} from '../model/types' +import type { ExportAction, ExportFrame, ExportPackageModel, ExportSequence } from './model' export type AssetExportPhase = 'collecting' | 'rendering' | 'packing' @@ -27,14 +22,14 @@ export interface AssetExportRuntime { } export interface PlannedFrame { - frame: PreviewFrame + frame: ExportFrame index: number filename: string } export interface PlannedSequence { - action: PreviewAction - sequence: PreviewSequence + action: ExportAction + sequence: ExportSequence folder: string columns: number rows: number @@ -80,7 +75,7 @@ function extensionForBlob(blob: Blob, imageUrl: string): string { return byMime[blob.type.toLowerCase()] ?? extensionFromUrl(imageUrl) } -export function createAssetExportPlan(model: PlaytestPreviewModel): readonly PlannedSequence[] { +export function createAssetExportPlan(model: ExportPackageModel): readonly PlannedSequence[] { return model.actions.flatMap((action) => { const actionFolder = `${safeSegment(action.name, 'action')}-${idSuffix(action.id)}` return action.sequences.flatMap((sequence) => { @@ -238,7 +233,7 @@ function storedZip(entries: readonly ZipEntry[]): Blob { } export async function exportGameAssets( - model: PlaytestPreviewModel, + model: ExportPackageModel, runtime: AssetExportRuntime = defaultRuntime, onPhase?: (phase: AssetExportPhase) => void, ): Promise { diff --git a/frontend/src/pages/playtest/workbench/export/export-panel.test.tsx b/frontend/src/features/export-package/export-panel.test.tsx similarity index 98% rename from frontend/src/pages/playtest/workbench/export/export-panel.test.tsx rename to frontend/src/features/export-package/export-panel.test.tsx index 908829d..56d07a4 100644 --- a/frontend/src/pages/playtest/workbench/export/export-panel.test.tsx +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { PlaytestPreviewModel } from '../model/types' +import type { ExportPackageModel as PlaytestPreviewModel } from './model' import { ExportPanel } from './export-panel' const model = { diff --git a/frontend/src/pages/playtest/workbench/export/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx similarity index 97% rename from frontend/src/pages/playtest/workbench/export/export-panel.tsx rename to frontend/src/features/export-package/export-panel.tsx index 16fd7fd..cea311a 100644 --- a/frontend/src/pages/playtest/workbench/export/export-panel.tsx +++ b/frontend/src/features/export-package/export-panel.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import type { PlaytestPreviewModel } from '../model/types' +import type { ExportPackageModel } from './model' import { createAssetExportPlan, exportGameAssets, @@ -9,12 +9,12 @@ import { } from './asset-export' export type AssetExporter = ( - model: PlaytestPreviewModel, + model: ExportPackageModel, onPhase?: (phase: AssetExportPhase) => void, ) => Promise export interface ExportPanelProps { - model: PlaytestPreviewModel + model: ExportPackageModel qualityIssueCount?: number exporter?: AssetExporter } diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts new file mode 100644 index 0000000..43dc84d --- /dev/null +++ b/frontend/src/features/export-package/index.ts @@ -0,0 +1,10 @@ +/** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ +export { ExportPanel } from './export-panel' +export type { ExportPackageModel } from './model' +export { + createAssetExportPlan, + exportGameAssets, + type AssetExportPhase, + type AssetExportResult, + type AssetExportRuntime, +} 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 0000000..28577bd --- /dev/null +++ b/frontend/src/features/export-package/model.ts @@ -0,0 +1,32 @@ +import type { ActionType, Frame } from '@/entities' + +/** 导出功能只依赖这份只读模型,不依赖 Playtest 页面内部实现。 */ +export interface ExportFrame { + imageUrl: string + durationMs: number + rootMotion: Frame['rootMotion'] + keyFrame: boolean +} + +export interface ExportSequence { + direction: string + frames: readonly ExportFrame[] +} + +export interface ExportAction { + id: string + name: string + type: ActionType | 'crouch' + fps: number + sequences: readonly ExportSequence[] +} + +export interface ExportPackageModel { + characterId: string + characterName: string + outfitId: string + outfitName: string + characterTemplateUrl: string | null + baseFrameCount: number + actions: readonly ExportAction[] +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts deleted file mode 100644 index 726b600..0000000 --- a/frontend/src/features/export/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** 为已完成版本记录导出状态;不承担内容复用或资源集合写入。 */ -export interface ExportProps { - runId: string - characterId: string - outfitId: string -} diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts deleted file mode 100644 index 2015752..0000000 --- a/frontend/src/features/generation/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** 触发并展示一次生成;不感知后端调用的是哪个模型。 */ -export interface GenerationProps { - runId: string - /** - * 目标动作,生成母版等非动作任务可以省略。 - * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, - * 不能脱离 run 单独使用这个字段。 - */ - actionId?: string -} diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts new file mode 100644 index 0000000..b8448d0 --- /dev/null +++ b/frontend/src/features/publish/index.ts @@ -0,0 +1,63 @@ +/** 已经写入 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}?actionId=${encodeURIComponent(target.actionId)}` : path +} + +const ACTION_NAMES: Record = { + idle: '待机', + walk: '行走', + jump: '跳跃', + attack: '攻击', + custom: '自定义动作', +} + +/** 审核通过时才把 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 = `${character.id}-${result.actionType}` + const action = { + id: actionId, + outfitId: outfit.id, + name: ACTION_NAMES[result.actionType], + 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 0000000..2246be0 --- /dev/null +++ b/frontend/src/features/publish/workflow-to-character.ts @@ -0,0 +1,140 @@ +/** + * 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 || '动作', + 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 3a48b66..3b9d154 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 0000000..220b260 --- /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 index d0656b7..80002d7 100644 --- a/frontend/src/features/workflow-controller/character-template-task.ts +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -1,9 +1,8 @@ import { parseCharacterTemplateGenerationResult, + type Generation, type GenerationApis, - type Task, - type TaskApis, - type TaskEvent, + type GenerationEvent, type WorkflowRevision, type WorkflowRun, type WorkflowRunStore, @@ -40,21 +39,19 @@ export interface CharacterTemplateTask { interface CreateCharacterTemplateTaskOptions { store: WorkflowRunStore - generationApis: Pick - taskApis: TaskApis + generationApis: GenerationApis createSubmissionId: () => string } /** * 角色图异步任务的生命周期。 * - * 它只处理当前角色图步骤与后端 Task 的关联,不决定整个工作流下一步走什么。 + * 它只处理当前角色图步骤与后端 Generation 的关联,不决定整个工作流下一步走什么。 * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 */ export function createCharacterTemplateTask({ store, generationApis, - taskApis, createSubmissionId, }: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { const submissions = new Map>() @@ -155,8 +152,18 @@ export function createCharacterTemplateTask({ ) { return latest } - if (generation.type !== 'character_template' || generation.projectId !== latest.projectId) { - throw new Error('生成任务返回的类型或项目与当前 WorkflowRun 不匹配') + 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) => { @@ -202,8 +209,8 @@ export function createCharacterTemplateTask({ subscriptions.set(key, { runId: run.id, stop: () => undefined }) try { - const stop = taskApis.subscribe(run.projectId, taskId, (event) => { - handleTaskEvent(run.id, { revisionId, stepId }, taskId, event) + 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 }) @@ -214,11 +221,11 @@ export function createCharacterTemplateTask({ } } - function handleTaskEvent( + function handleGenerationEvent( runId: WorkflowRun['id'], target: WorkflowStepTarget, taskId: string, - event: TaskEvent, + event: GenerationEvent, ) { if (event.taskId !== taskId) return if (event.status === 'pending' || event.status === 'running') return @@ -260,7 +267,7 @@ export function createCharacterTemplateTask({ ) } if (activeStep.taskId) { - const task = await taskApis.get(run.projectId, 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 @@ -289,7 +296,7 @@ export function createCharacterTemplateTask({ if (task.status === 'pending' || task.status === 'running') { ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) } else { - handleTaskEvent( + handleGenerationEvent( latest.id, { revisionId: latestRevision.id, stepId: latestStep.id }, latestStep.taskId, @@ -425,7 +432,7 @@ export function createCharacterTemplateTask({ return { start, resume, stop } } -function taskEvent(task: Task): TaskEvent { +function taskEvent(task: Generation): GenerationEvent { return { taskId: task.id, type: task.type, diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index a8b6320..89dd488 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -4,10 +4,8 @@ import { WORKFLOW_STEP_ORDER, type Generation, type GenerationApis, + type GenerationEvent, type GenerationInput, - type Task, - type TaskApis, - type TaskEvent, type WorkflowRevision, type WorkflowRun, type WorkflowStep, @@ -26,6 +24,7 @@ function cloneRun(run: WorkflowRun): WorkflowRun { 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) @@ -39,6 +38,7 @@ function createMemoryStore() { 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) => { @@ -51,7 +51,13 @@ function createMemoryStore() { } }) - return { get, save, subscribe } + 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() { @@ -83,7 +89,7 @@ function pendingCharacterTemplateGeneration(): Generation<'character_template'> function createHarness() { const store = createMemoryStore() - const taskListeners = new Map void>() + const taskListeners = new Map void>() const createGeneration: GenerationApis['create'] = async (input: T) => ({ @@ -95,12 +101,8 @@ function createHarness() { error: null, }) as Generation - const generationApis: Pick = { - create: vi.fn(createGeneration), - } - const subscribeTask = vi.fn( - (projectId: string, taskId: string, onEvent: (event: TaskEvent) => void) => { + (projectId: string, taskId: string, onEvent: (event: GenerationEvent) => void) => { taskListeners.set(`${projectId}:${taskId}`, onEvent) onEvent({ taskId, @@ -114,10 +116,10 @@ function createHarness() { } }, ) - - const taskApis: TaskApis = { + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), get: vi.fn(async () => { - throw new Error('TaskApis.get is not used until a run is resumed') + throw new Error('GenerationApis.get is not used until a run is resumed') }), subscribe: subscribeTask, } @@ -125,7 +127,6 @@ function createHarness() { const controller = createWorkflowController({ store, generationApis, - taskApis, createId: createIdFactory(), now: () => NOW, }) @@ -135,11 +136,10 @@ function createHarness() { generationApis, subscribeTask, store, - taskApis, getTaskListener(projectId: string, taskId: string) { return taskListeners.get(`${projectId}:${taskId}`) ?? null }, - emitTask(projectId: string, taskId: string, event: TaskEvent) { + emitTask(projectId: string, taskId: string, event: GenerationEvent) { const listener = taskListeners.get(`${projectId}:${taskId}`) expect(listener, `missing task subscription for ${projectId}:${taskId}`).toBeTypeOf( 'function', @@ -174,9 +174,11 @@ async function createAiRun(harness: ReturnType) { }) } +const SPRITE_SIZE = { width: 64, height: 64 } + async function startCharacterTemplate(harness: ReturnType) { const run = await createAiRun(harness) - await harness.controller.nextStep(run.id) + await harness.controller.nextStep(run.id, SPRITE_SIZE) return run } @@ -236,6 +238,8 @@ describe('createWorkflowController', () => { projectId: 'project-1', prompt: 'pixel knight', referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, }) expect(harness.subscribeTask).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) @@ -266,8 +270,8 @@ describe('createWorkflowController', () => { const run = await createAiRun(harness) const deferred = deferNextGeneration(harness) - const first = harness.controller.nextStep(run.id) - const second = harness.controller.nextStep(run.id) + 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()) @@ -284,7 +288,7 @@ describe('createWorkflowController', () => { const run = await createAiRun(harness) const deferred = deferNextGeneration(harness) - const submission = harness.controller.nextStep(run.id) + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) const resumed = await harness.controller.resume(run.id) expect(resumed?.status).toBe('active') @@ -321,7 +325,7 @@ describe('createWorkflowController', () => { }) const run = await createAiRun(harness) - await harness.controller.nextStep(run.id) + await harness.controller.nextStep(run.id, SPRITE_SIZE) expect(step(harness.controller.getWorkflow(run.id)!, 'character-template')).toMatchObject({ status: 'passed', @@ -410,30 +414,27 @@ describe('createWorkflowController', () => { it('resumes a persisted in-flight task without creating another generation', async () => { const harness = createHarness() const run = await startCharacterTemplate(harness) - vi.mocked(harness.taskApis.get).mockResolvedValueOnce({ + 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: TaskEvent) => void) => () => + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => undefined, ) const resumedController = createWorkflowController({ store: harness.store, - generationApis: harness.generationApis, - taskApis: { - get: harness.taskApis.get, - subscribe: resumeSubscribe, - }, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, }) const resumed = await resumedController.resume(run.id) expect(resumed?.id).toBe(run.id) - expect(harness.taskApis.get).toHaveBeenCalledWith('project-1', 'task-1') + 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) }) @@ -441,8 +442,9 @@ describe('createWorkflowController', () => { it('applies a completed task found during refresh before subscribing again', async () => { const harness = createHarness() const run = await startCharacterTemplate(harness) - vi.mocked(harness.taskApis.get).mockResolvedValueOnce({ + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ id: 'task-1', + projectId: 'project-1', type: 'character_template', status: 'completed', error: null, @@ -452,16 +454,12 @@ describe('createWorkflowController', () => { }, }) const resumeSubscribe = vi.fn( - (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => undefined, ) const resumedController = createWorkflowController({ store: harness.store, - generationApis: harness.generationApis, - taskApis: { - get: harness.taskApis.get, - subscribe: resumeSubscribe, - }, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, }) const resumed = await resumedController.resume(run.id) @@ -477,18 +475,18 @@ describe('createWorkflowController', () => { 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: Task) => void - const pendingTask = new Promise((resolve) => { + let resolveTask!: (task: Generation) => void + const pendingTask = new Promise((resolve) => { resolveTask = resolve }) const resumeSubscribe = vi.fn( - (_projectId: string, _taskId: string, _onEvent: (event: TaskEvent) => void) => () => + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => undefined, ) const resumedController = createWorkflowController({ store: harness.store, - generationApis: harness.generationApis, - taskApis: { + generationApis: { + ...harness.generationApis, get: vi.fn(() => pendingTask), subscribe: resumeSubscribe, }, @@ -498,6 +496,7 @@ describe('createWorkflowController', () => { resumedController.interrupt(run.id) resolveTask({ id: 'task-1', + projectId: 'project-1', type: 'character_template', status: 'running', error: null, @@ -525,7 +524,6 @@ describe('createWorkflowController', () => { const restoredController = createWorkflowController({ store: harness.store, generationApis: harness.generationApis, - taskApis: harness.taskApis, }) const restored = await restoredController.resume(run.id) @@ -545,7 +543,7 @@ describe('createWorkflowController', () => { const run = await createAiRun(harness) const deferred = deferNextGeneration(harness) - const submission = harness.controller.nextStep(run.id) + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) await harness.controller.interrupt(run.id) deferred.resolve(pendingCharacterTemplateGeneration()) await submission @@ -603,10 +601,77 @@ describe('createWorkflowController', () => { 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) + 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) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 90349c7..d973eb4 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,17 +1,23 @@ import type { CharacterSetupStepInput, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, GenerationApis, - TaskApis, WorkflowRun, WorkflowRunStore, } from '@/entities' +import { createActionGenerationTask } from './action-generation-task' import { createCharacterTemplateTask } from './character-template-task' import { advanceCharacterSetupState, + approveReviewState, + completeActionGenerationState, + confirmCandidateState, createWorkflowRunState, getActiveStep, getCurrentRevision, interruptWorkflowRunState, + recordActionGenerationTaskState, restartWorkflowRunState, requireActiveWorkflow, updateCharacterSetupState, @@ -37,8 +43,12 @@ export interface WorkflowController { /** * 推进一个步骤。当前纵切只实现角色资料到角色图生成; * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + * spriteSize 为项目精灵图尺寸,角色图生成步骤需要传给后端做尺寸校验。 */ - nextStep(runId: WorkflowRun['id']): Promise + nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ resume(runId: WorkflowRun['id']): Promise @@ -46,14 +56,40 @@ export interface WorkflowController { /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ 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: Pick - taskApis: TaskApis + generationApis: GenerationApis /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ createId?: (scope: 'run' | 'revision' | 'submission') => string /** 测试可注入确定性时间。 */ @@ -70,14 +106,17 @@ export interface CreateWorkflowControllerOptions { export function createWorkflowController({ store, generationApis, - taskApis, createId = createRuntimeId, now = () => new Date().toISOString(), }: CreateWorkflowControllerOptions): WorkflowController { const characterTemplateTask = createCharacterTemplateTask({ store, generationApis, - taskApis, + createSubmissionId: () => createId('submission'), + }) + const actionGenerationTask = createActionGenerationTask({ + store, + generationApis, createSubmissionId: () => createId('submission'), }) @@ -117,7 +156,10 @@ export function createWorkflowController({ return save(updateCharacterSetupState(requireWorkflow(runId), input)) } - async function nextStep(runId: WorkflowRun['id']): Promise { + 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) @@ -133,13 +175,20 @@ export function createWorkflowController({ throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) } - const transitioned = advanceCharacterSetupState(run) + if (!spriteSize) throw new Error('推进角色资料步骤需要项目精灵图尺寸') + + const transitioned = advanceCharacterSetupState(run, spriteSize) save(transitioned.run) return characterTemplateTask.start(runId, transitioned.target) } function resume(runId: WorkflowRun['id']) { - return characterTemplateTask.resume(runId) + 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 { @@ -147,13 +196,69 @@ export function createWorkflowController({ 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'), @@ -168,6 +273,12 @@ export function createWorkflowController({ subscribe, updateCharacterSetup, nextStep, + confirmCandidate, + completeActionGeneration, + startActionGeneration, + approveReview, + recordActionGenerationTask, + recordCharacterRefs, restart, resume, interrupt, 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 0000000..9ea98ac --- /dev/null +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -0,0 +1,218 @@ +/** + * 状态机存储不变量穷举测试。 + * + * 每个状态转换点之后,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('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 index ca4cc9a..62d8f41 100644 --- a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -4,16 +4,15 @@ import { createWorkflowRunStore, type Generation, type GenerationApis, + type GenerationEvent, type GenerationInput, - type TaskApis, - type TaskEvent, } 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: TaskEvent) => void } = {} + const taskChannel: { listener?: (event: GenerationEvent) => void } = {} const createGeneration: GenerationApis['create'] = async ( input: T, @@ -27,10 +26,8 @@ describe('WorkflowRun first vertical slice', () => { error: null, }) as Generation - const generationApis: Pick = { + const generationApis: GenerationApis = { create: vi.fn(createGeneration), - } - const taskApis: TaskApis = { get: vi.fn(async () => { throw new Error('not used in this slice') }), @@ -52,7 +49,6 @@ describe('WorkflowRun first vertical slice', () => { const controller = createWorkflowController({ store, generationApis, - taskApis, createId: () => ids.shift() ?? 'unexpected-id', now: () => '2026-07-30T12:00:00.000Z', }) @@ -64,7 +60,7 @@ describe('WorkflowRun first vertical slice', () => { prompt: '像素骑士', }) - await controller.nextStep(created.id) + await controller.nextStep(created.id, { width: 64, height: 64 }) const inFlight = store.get(created.id) expect( diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts index ff98c53..5e2924a 100644 --- a/frontend/src/features/workflow-controller/workflow-state.test.ts +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { advanceCharacterSetupState, + approveReviewState, createWorkflowRunState, restartWorkflowRunState, updateCharacterSetupState, @@ -69,7 +70,7 @@ describe('workflow state transitions', () => { referenceMedia: [], }) - const transitioned = advanceCharacterSetupState(run) + const transitioned = advanceCharacterSetupState(run, { width: 64, height: 64 }) expect(transitioned.target).toEqual({ revisionId: 'revision-1', @@ -85,6 +86,8 @@ describe('workflow state transitions', () => { projectId: 'project-1', prompt: 'revised knight', referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, }, }, { type: 'template-candidate', status: 'locked' }, @@ -92,7 +95,7 @@ describe('workflow state transitions', () => { }) it('creates a new revision from a passed stage without retaining downstream outputs', () => { - const prepared = advanceCharacterSetupState(createRun()).run + const prepared = advanceCharacterSetupState(createRun(), { width: 64, height: 64 }).run const sourceRevision = prepared.revisions[0]! const run = { ...prepared, @@ -157,4 +160,25 @@ describe('workflow state transitions', () => { }), ).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 index 4b0d7d6..8dc5760 100644 --- a/frontend/src/features/workflow-controller/workflow-state.ts +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -2,6 +2,8 @@ import { WORKFLOW_STEP_ORDER, type CharacterSetupStepInput, type CharacterTemplateGenerationInput, + type CompleteAnimationGenerationInput, + type CompleteAnimationGenerationResult, type CreateWorkflowRunInput, type WorkflowRevision, type WorkflowRun, @@ -125,7 +127,10 @@ export function updateCharacterSetupState( }) } -export function advanceCharacterSetupState(workflow: WorkflowRun): { +export function advanceCharacterSetupState( + workflow: WorkflowRun, + spriteSize: { width: number; height: number }, +): { run: WorkflowRun target: WorkflowStepTarget } { @@ -146,6 +151,8 @@ export function advanceCharacterSetupState(workflow: WorkflowRun): { projectId: run.projectId, prompt: activeStep.input.description, referenceMedia: activeStep.input.referenceMedia, + spriteWidth: spriteSize.width, + spriteHeight: spriteSize.height, } return { @@ -175,6 +182,163 @@ export function advanceCharacterSetupState(workflow: WorkflowRun): { } } +/** + * 确认候选选择:标记 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 } diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx new file mode 100644 index 0000000..9765112 --- /dev/null +++ b/frontend/src/pages/asset-library/index.tsx @@ -0,0 +1,70 @@ +/** 资产库页面:读取后端已经保存的 Character 树,不展示运行中的 WorkflowRun。 */ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' + +import type { Character, CharacterApis } from '@/entities' + +export function AssetLibraryPage({ apis }: { apis: CharacterApis }) { + const { projectId = '' } = useParams() + const [characters, setCharacters] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + if (!projectId) return + void apis.listByProject(projectId).then( + (items) => + active && + setCharacters( + items.filter((character) => + character.outfits.some((outfit) => outfit.actions.length > 0), + ), + ), + (cause) => active && setError(cause instanceof Error ? cause.message : '资产加载失败'), + ) + return () => { + active = false + } + }, [apis, projectId]) + + return ( +
    +

    ASSET LIBRARY

    +

    资产库

    +

    项目中已经发布的角色、造型和动作。

    + {error && ( +

    + {error} +

    + )} + {!error && characters.length === 0 && ( +

    + 这个项目还没有已发布资产。 +

    + )} +
    + {characters.flatMap((character) => + character.outfits.map((outfit) => ( +
    + {outfit.characterTemplateUrl && ( + {`${outfit.name} + )} +

    {outfit.name}

    +

    {outfit.actions.length} 个动作

    + + 进入预览台 + +
    + )), + )} +
    +
    + ) +} diff --git a/frontend/src/pages/history/index.tsx b/frontend/src/pages/history/index.tsx index 470b67b..8152ed5 100644 --- a/frontend/src/pages/history/index.tsx +++ b/frontend/src/pages/history/index.tsx @@ -1,36 +1,19 @@ /** * 历史记录页面 — 读取 WorkflowRunStore 展示已完成的工作流。 */ -import { useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router' +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router' -import type { WorkflowRun } from '@/entities' -import { createWorkflowRunStore, type WorkflowRunStore } from '@/entities/workflow-run/store' +import type { WorkflowRun, WorkflowRunStore } from '@/entities' -export interface HistoryPageProps { - store?: WorkflowRunStore -} - -export function HistoryPage({ store }: HistoryPageProps) { - const defaultStore = useMemo(() => store ?? createWorkflowRunStore(), [store]) - const [runs, setRuns] = useState([]) +export function HistoryPage({ store }: { store: WorkflowRunStore }) { + const { projectId } = useParams() + const [runs, setRuns] = useState(() => filterRuns(store.list(), projectId)) useEffect(() => { - // 从 localStorage 读取所有 run - const allRuns = defaultStore.get('__list__') // hack: store doesn't have list, read directly - // 实际上 store 没有 list 方法,需要直接读 localStorage - try { - const raw = localStorage.getItem('windup.workflow-runs') - if (raw) { - const parsed = JSON.parse(raw) - if (Array.isArray(parsed.runs)) { - setRuns(parsed.runs as WorkflowRun[]) - } - } - } catch { - setRuns([]) - } - }, [defaultStore]) + 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') @@ -41,12 +24,8 @@ export function HistoryPage({ store }: HistoryPageProps) {

    HISTORY

    -

    - 历史记录 -

    -

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

    +

    历史记录

    +

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

    {activeRuns.length > 0 && ( @@ -86,21 +65,30 @@ export function HistoryPage({ store }: HistoryPageProps) { ) } +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' ? '已中断' - : '进行中' + run.status === 'completed' + ? '已完成' + : run.status === 'failed' + ? '失败' + : run.status === 'interrupted' + ? '已中断' + : '进行中' const statusColor = - run.status === 'completed' ? 'text-[#3d6b4a]' - : run.status === 'failed' ? 'text-[#8b332a]' - : 'text-[#687069]' + run.status === 'completed' + ? 'text-[#3d6b4a]' + : run.status === 'failed' + ? 'text-[#8b332a]' + : 'text-[#687069]' return (
    - - {statusLabel} - + {statusLabel} ) } diff --git a/frontend/src/pages/home/index.test.tsx b/frontend/src/pages/home/index.test.tsx index dbf9396..4410ffa 100644 --- a/frontend/src/pages/home/index.test.tsx +++ b/frontend/src/pages/home/index.test.tsx @@ -18,7 +18,7 @@ describe('HomePage', () => { expect(screen.getByRole('heading', { name: /真正登场/ })).toBeTruthy() expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start') expect(screen.getByRole('link', { name: /新建项目/ }).getAttribute('href')).toBe( - '/projects/new', + '/workflow-editor', ) expect(screen.getByRole('link', { name: '查看项目历史' }).getAttribute('href')).toBe( '/projects', diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 31b6a86..0675181 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -82,7 +82,7 @@ export function HomePage() { eyebrow="PROJECT SETUP" index="02" title="新建项目" - description="建立项目后,沿可回退的五步工作流制作角色、动作与完成版本。" + description="建立项目后,沿可回退的节点工作流制作角色、动作与完成版本。" actionLabel="开始创建项目" secondaryAction={{ to: '/projects', label: '查看项目历史' }} /> diff --git a/frontend/src/pages/playtest/demo-page.tsx b/frontend/src/pages/playtest/demo-page.tsx index ab71b54..9bc227b 100644 --- a/frontend/src/pages/playtest/demo-page.tsx +++ b/frontend/src/pages/playtest/demo-page.tsx @@ -1,17 +1,61 @@ +import { useMemo } from 'react' +import { useLocation } from 'react-router' + +import type { Character } from '@/entities/character' import { PLAYTEST_DEMO_CHARACTER, + PLAYTEST_DEMO_CHARACTER_ID, PLAYTEST_DEMO_ACTION_ID, PLAYTEST_DEMO_OUTFIT_ID, } from './testing/demo-character' import { PlaytestWorkbench } from './workbench' -/** Explicit development fixture entry point. It intentionally bypasses all production APIs. */ +interface DemoPageState { + characterImageUrl?: string +} + +/** + * 开发预览入口。 + * + * 当从 Quick Start 导出时,location.state 携带 characterImageUrl, + * 用选中的角色图覆盖 demo 角色的母版和 base frame。 + */ export function PlaytestDemoPage() { + const location = useLocation() + const state = location.state as DemoPageState | null + const characterImageUrl = state?.characterImageUrl + + const character = useMemo( + () => + characterImageUrl + ? overrideCharacterImage(PLAYTEST_DEMO_CHARACTER, characterImageUrl) + : PLAYTEST_DEMO_CHARACTER, + [characterImageUrl], + ) + return ( ) } + +function overrideCharacterImage(base: Character, imageUrl: string): Character { + return { + ...base, + outfits: base.outfits.map((outfit) => ({ + ...outfit, + characterTemplateUrl: imageUrl, + candidateCharacterTemplates: [ + { + id: `${PLAYTEST_DEMO_CHARACTER_ID}-quick-start`, + imageUrl, + attemptId: 'quick-start-export', + }, + ], + baseFrames: [{ imageUrl }], + })), + } +} diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 99d0a2d..eea852b 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -34,6 +34,7 @@ function isNotFoundError(error: unknown): boolean { /** * 正式 Playtest 页面只读取 #70 已定义的 Character 接口。 + * 当接口未配置时,自动回退到内置 demo 角色素材。 * 核验与自动分析结果均停留在页面会话,不写回资产树。 */ export function PlaytestPage({ apis }: PlaytestPageProps) { @@ -43,8 +44,13 @@ export function PlaytestPage({ apis }: PlaytestPageProps) { const [data, setData] = useState(initialPageData) useEffect(() => { + // 正式入口未配置角色接口时明确提示,不加载也不回退到 Demo 少年数据 if (apis === undefined) { - setData({ ...initialPageData, error: 'Playtest 角色接口尚未配置' }) + setData({ + character: null, + error: 'Playtest 角色接口尚未配置', + loading: false, + }) return } if (characterId === undefined || outfitId === undefined) { diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts index 9f59979..b8ff09d 100644 --- a/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts @@ -13,19 +13,15 @@ function pixelReadFailure(error: unknown): FrameGeometryResult { return unavailable('浏览器无法读取图片像素') } -let sharedCanvas: HTMLCanvasElement | null = null -let sharedContext: CanvasRenderingContext2D | null = null - +/** + * 每次读取创建独立 canvas。帧检查是低频操作(每帧 onload 一次), + * 不缓存可避免模块级状态在测试间泄漏(mock 无法重置)。 + */ function getSharedContext(width: number, height: number): CanvasRenderingContext2D | null { - if (sharedCanvas === null) { - sharedCanvas = document.createElement('canvas') - sharedContext = sharedCanvas.getContext('2d', { willReadFrequently: true }) - } - if (sharedCanvas.width !== width || sharedCanvas.height !== height) { - sharedCanvas.width = width - sharedCanvas.height = height - } - return sharedContext + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas.getContext('2d', { willReadFrequently: true }) } export function readImageGeometry( diff --git a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts index f7373a3..37d85a0 100644 --- a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts +++ b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts @@ -11,6 +11,7 @@ export interface LocalQualityPolicy { edgeMargin: { x: number; y: number } minimumCoverageRatio: number maximumCoverageRatio: number + /** 相邻帧指纹平均距离 ≤ 该值时判定为重复帧(duplicate_frame)。 */ duplicateDistance: number footDriftThreshold: number | null heightDriftThreshold: number | null @@ -88,6 +89,7 @@ export function deriveLocalQualityPolicy( minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, duplicateDistance: DUPLICATE_DISTANCE, + footDriftThreshold: null, heightDriftThreshold: null, heightAttentionThreshold: null, diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx index 06fb3c6..d3bca47 100644 --- a/frontend/src/pages/playtest/workbench/animation-stage.tsx +++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx @@ -3,6 +3,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { PreviewFrame } from './model/types' import type { HorizontalStageBounds, StageOffset } from './stage-motion' +const ZOOM_LEVELS = [0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8] as const +const DEFAULT_ZOOM = 4 // 64px sprite at 4x = 256px on screen +/** 高分辨率精灵(>=192px)首次加载时用 1x,避免 256px 素材被默认放大到 1024px。 */ +const HIGH_RES_ZOOM = 1 +const HIGH_RES_THRESHOLD_PX = 192 + export interface AnimationStageProps { currentFrame: PreviewFrame | null /** Accumulated playback position in world coordinates (positive y is up). */ @@ -22,9 +28,21 @@ export function AnimationStage({ onHorizontalBoundsChange, }: AnimationStageProps) { const [failedImageUrl, setFailedImageUrl] = useState(null) + const [zoomIndex, setZoomIndex] = useState(() => ZOOM_LEVELS.indexOf(DEFAULT_ZOOM)) const stageRef = useRef(null) const imageRef = useRef(null) + const zoom = ZOOM_LEVELS[zoomIndex] ?? DEFAULT_ZOOM + const zoomIn = useCallback(() => { + setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1)) + }, []) + const zoomOut = useCallback(() => { + setZoomIndex((i) => Math.max(i - 1, 0)) + }, []) + const resetZoom = useCallback(() => { + setZoomIndex(ZOOM_LEVELS.indexOf(DEFAULT_ZOOM)) + }, []) + const reportHorizontalBounds = useCallback(() => { if (onHorizontalBoundsChange === undefined) return const stage = stageRef.current @@ -48,6 +66,23 @@ export function AnimationStage({ setFailedImageUrl(null) }, [currentFrame?.imageUrl]) + // Mouse wheel zoom on stage + useEffect(() => { + const stage = stageRef.current + if (stage === null) return + const handleWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return + e.preventDefault() + setZoomIndex((i) => { + if (e.deltaY < 0) return Math.min(i + 1, ZOOM_LEVELS.length - 1) + if (e.deltaY > 0) return Math.max(i - 1, 0) + return i + }) + } + stage.addEventListener('wheel', handleWheel, { passive: false }) + return () => stage.removeEventListener('wheel', handleWheel) + }, []) + useEffect(() => { reportHorizontalBounds() if (typeof ResizeObserver === 'undefined') { @@ -99,14 +134,49 @@ export function AnimationStage({ ref={imageRef} src={currentFrame.imageUrl} alt="角色动画预览" - onLoad={reportHorizontalBounds} + onLoad={(event) => { + reportHorizontalBounds() + if (event.currentTarget.naturalWidth >= HIGH_RES_THRESHOLD_PX) { + setZoomIndex(ZOOM_LEVELS.indexOf(HIGH_RES_ZOOM)) + } + }} onError={() => setFailedImageUrl(currentFrame.imageUrl)} style={{ - transform: `translate(${motionOffset.x}px, ${-motionOffset.y}px) scaleX(${mirrored ? -1 : 1})`, + transform: `translate(${motionOffset.x}px, ${-motionOffset.y}px) scaleX(${mirrored ? -1 : 1}) scale(${zoom})`, }} className="relative z-10 max-h-[68%] max-w-[68%] object-contain drop-shadow-[0_18px_12px_rgba(15,23,42,0.12)] [image-rendering:pixelated]" /> )} + + {/* Zoom controls */} +
    + + + +
    ) } diff --git a/frontend/src/pages/playtest/workbench/index.tsx b/frontend/src/pages/playtest/workbench/index.tsx index 323f055..6cc4aac 100644 --- a/frontend/src/pages/playtest/workbench/index.tsx +++ b/frontend/src/pages/playtest/workbench/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useReducer, useRef, useState, type KeyboardEvent } from 'react' +import { useCallback, useMemo, useReducer, useState, type KeyboardEvent } from 'react' import type { Character } from '@/entities/character' @@ -9,7 +9,7 @@ import { AnimationStage } from './animation-stage' import { AuditPanel } from './audit/audit-panel' import { reduceAuditSession } from './audit/audit-session' import { FrameTimeline } from './frame-timeline' -import { ExportPanel } from './export/export-panel' +import { ExportPanel } from '@/features/export-package' import { Inspector } from './inspector' import { createPreviewModel } from './model/create-preview-model' import type { PreviewAction } from './model/types' @@ -50,7 +50,6 @@ export function PlaytestWorkbench({ nextFrame, previousFrame, playActionType, - continueActionType, toggleLoop, togglePlaying, } = playback @@ -79,7 +78,6 @@ export function PlaytestWorkbench({ const [activeRightPanel, setActiveRightPanel] = useState('inspect') const [actionSidebarCollapsed, setActionSidebarCollapsed] = useState(false) const [manualIssues, dispatchAudit] = useReducer(reduceAuditSession, []) - const horizontalHoldStartedPaused = useRef(null) const frameCount = playback.sequence?.frames.length ?? 0 const automaticFindings = @@ -106,31 +104,27 @@ export function PlaytestWorkbench({ const keyboardCommands = useMemo( () => ({ togglePlaying, - previousFrame, - nextFrame, + previousFrame: () => { + if (isPlaying) togglePlaying() + previousFrame() + }, + nextFrame: () => { + if (isPlaying) togglePlaying() + nextFrame() + }, firstFrame, lastFrame, toggleLoop, playLeft: () => { - if (playback.action === null) return - if (horizontalHoldStartedPaused.current === null) { - horizontalHoldStartedPaused.current = !isPlaying - } - if (playback.action.type === 'walk') setMirrored(true) - continueActionType(playback.action.type) + if (isPlaying) togglePlaying() + previousFrame() }, playRight: () => { - if (playback.action === null) return - if (horizontalHoldStartedPaused.current === null) { - horizontalHoldStartedPaused.current = !isPlaying - } - if (playback.action.type === 'walk') setMirrored(false) - continueActionType(playback.action.type) + if (isPlaying) togglePlaying() + nextFrame() }, stopHorizontal: () => { - const shouldRestorePause = horizontalHoldStartedPaused.current === true - horizontalHoldStartedPaused.current = null - if (shouldRestorePause && isPlaying) togglePlaying() + // A/D 松开后无需恢复播放,用户通过空格控制播放 }, playJump: () => { if (!jumpAvailable) return @@ -147,12 +141,9 @@ export function PlaytestWorkbench({ nextFrame, previousFrame, playActionType, - continueActionType, toggleLoop, isPlaying, - setMirrored, togglePlaying, - playback.action?.type, jumpAvailable, crouchAvailable, ], @@ -228,6 +219,18 @@ export function PlaytestWorkbench({ className="flex flex-wrap items-center gap-2 rounded-xl border border-slate-200 bg-white p-3 shadow-sm" > 方向 + {playback.action?.sequences.map((sequence) => ( +