diff --git a/.env.example b/.env.example
index f7962b98..affa5e38 100644
--- a/.env.example
+++ b/.env.example
@@ -18,13 +18,6 @@ REDIS_URL=redis://localhost:6379/0
# 数据持久化目录(默认 ./data/redis)
REDIS_DATA_DIR=./data/redis
-# ── LLM 配置 ──
-LLM_API_KEY=your-api-key-here
-LLM_MODEL_ID=doubao-seed-2.0-mini
-LLM_BASE_URL=https://api.qnaigc.com/v1
-LLM_IMAGE_MODEL_ID=gemini-3.0-pro-image-preview
-LLM_VIDEO_MODEL_ID=
-
# ── 搜索 API ──
SERPAPI_API_KEY=your-serpapi-key
@@ -32,12 +25,27 @@ SERPAPI_API_KEY=your-serpapi-key
QINIU_ACCESS_KEY=your-access-key
QINIU_SECRET_KEY=your-secret-key
QINIU_BUCKET_NAME=your-bucket
-QINIU_BUCKET_DOMAIN=your-domain.com
+# Bucket 绑定的浏览器可访问 HTTPS Kodo/CDN 域名,不能填 *.s3.*.qiniucs.com API 端点
+QINIU_BUCKET_DOMAIN=https://cdn.example.com
QINIU_PRIVATE_SPACE=false
# ── AI Provider ──
+# 键名前缀必须是 AI_,由 framework/config/provider.py 的 env_prefix 决定。
AI_BASE_URL=https://api.qnaigc.com/v1
AI_API_KEY=your-ai-api-key
+# 各能力分开配型号:同时在用不同模型,共用一个字段会换一个连带换全部。
+# 取值即默认值——不写这几行时跑的就是它们。
+AI_CHAT_MODEL=gpt-4o-mini
+AI_IMAGE_MODEL=gemini-2.5-flash-image
+AI_VIDEO_MODEL=kling-v2-5-turbo
+
+# ── 积分定价 ──
+QUOTA_REGISTER_GIFT_AMOUNT=300
+QUOTA_INVITE_REWARD_AMOUNT=200
+QUOTA_INVITE_REWARD_DAILY_LIMIT=3
+QUOTA_INVITE_CODE_TTL_DAYS=30
+QUOTA_GENERATE_IMAGE_COST=10
+QUOTA_GENERATE_ACTION_COST=50
# ── 服务配置 ──
WINDUP_HOST=127.0.0.1
@@ -47,5 +55,5 @@ WINDUP_PORT=8000
VITE_API_BASE_URL=/api
# 跨域来源(逗号分隔,默认覆盖 localhost:5173/3000)
WINDUP_CORS_ORIGINS=
-# 跨域正则匹配(默认允许 *.vercel.app)
-WINDUP_CORS_ORIGIN_REGEX=https://.*\.vercel\.app
+# 跨域正则匹配(默认关闭;如需 Vercel 预览域名,请限制到自己的项目名前缀)
+WINDUP_CORS_ORIGIN_REGEX=
diff --git a/.github/assets/account-center/account-center-desktop.jpg b/.github/assets/account-center/account-center-desktop.jpg
new file mode 100644
index 00000000..d9630880
Binary files /dev/null and b/.github/assets/account-center/account-center-desktop.jpg differ
diff --git a/.github/assets/landing-page/capabilities-desktop.png b/.github/assets/landing-page/capabilities-desktop.png
new file mode 100644
index 00000000..0359cae6
Binary files /dev/null and b/.github/assets/landing-page/capabilities-desktop.png differ
diff --git a/.github/assets/landing-page/hero-desktop.png b/.github/assets/landing-page/hero-desktop.png
new file mode 100644
index 00000000..97606fd6
Binary files /dev/null and b/.github/assets/landing-page/hero-desktop.png differ
diff --git a/.github/assets/landing-page/pipeline-desktop.png b/.github/assets/landing-page/pipeline-desktop.png
new file mode 100644
index 00000000..70a61872
Binary files /dev/null and b/.github/assets/landing-page/pipeline-desktop.png differ
diff --git a/.github/assets/landing-page/styles-desktop.png b/.github/assets/landing-page/styles-desktop.png
new file mode 100644
index 00000000..f4477ccc
Binary files /dev/null and b/.github/assets/landing-page/styles-desktop.png differ
diff --git a/.github/assets/landing-page/workspace-desktop.png b/.github/assets/landing-page/workspace-desktop.png
new file mode 100644
index 00000000..6fba8883
Binary files /dev/null and b/.github/assets/landing-page/workspace-desktop.png differ
diff --git a/.github/assets/readme/character-journey.webp b/.github/assets/readme/character-journey.webp
new file mode 100644
index 00000000..9298c878
Binary files /dev/null and b/.github/assets/readme/character-journey.webp differ
diff --git a/.github/scripts/check-pr-issue.cjs b/.github/scripts/check-pr-issue.cjs
new file mode 100644
index 00000000..a6697f32
--- /dev/null
+++ b/.github/scripts/check-pr-issue.cjs
@@ -0,0 +1,66 @@
+const WARNING_MARKER = ''
+const RESOLVED_WARNING_MARKER = ''
+
+function isMissingIssueWarning(comment) {
+ return comment.body?.includes(WARNING_MARKER) && !comment.body.includes(RESOLVED_WARNING_MARKER)
+}
+
+async function checkPullRequestIssue({ github, context, core }) {
+ const pullRequest = context.payload.pull_request
+ const { owner, repo } = context.repo
+
+ const result = await github.graphql(
+ `query PullRequestClosingIssues($owner: String!, $repo: String!, $number: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $number) {
+ closingIssuesReferences(first: 1) {
+ totalCount
+ }
+ }
+ }
+ }`,
+ { owner, repo, number: pullRequest.number },
+ )
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: pullRequest.number,
+ per_page: 100,
+ })
+
+ if (result.repository.pullRequest.closingIssuesReferences.totalCount > 0) {
+ const warning = comments.find(isMissingIssueWarning)
+
+ if (warning) {
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: warning.id,
+ body: `${RESOLVED_WARNING_MARKER}\n✅ 此 PR 已关联 issue,之前的提醒已自动标记为已解决。`,
+ })
+ core.info('Resolved stale missing-issue warning.')
+ }
+
+ core.info('Pull request is linked to an issue.')
+ return
+ }
+
+ if (comments.some(isMissingIssueWarning)) {
+ core.info('Missing-issue warning has already been posted.')
+ return
+ }
+
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: pullRequest.number,
+ body: `${WARNING_MARKER}\n⚠️ @${pullRequest.user.login},此 PR 尚未关联 issue。请在 PR 描述中使用 \`Closes #123\` 等关闭关键字,或通过 Development 侧栏关联对应 issue。`,
+ })
+
+ core.warning('Pull request is not linked to an issue; posted an author warning.')
+}
+
+module.exports = checkPullRequestIssue
+module.exports.WARNING_MARKER = WARNING_MARKER
+module.exports.RESOLVED_WARNING_MARKER = RESOLVED_WARNING_MARKER
diff --git a/.github/scripts/check-pr-issue.test.cjs b/.github/scripts/check-pr-issue.test.cjs
new file mode 100644
index 00000000..97cb3a38
--- /dev/null
+++ b/.github/scripts/check-pr-issue.test.cjs
@@ -0,0 +1,153 @@
+const assert = require('node:assert/strict')
+const test = require('node:test')
+
+const checkPullRequestIssue = require('./check-pr-issue.cjs')
+const { RESOLVED_WARNING_MARKER, WARNING_MARKER } = checkPullRequestIssue
+
+function createContext() {
+ return {
+ repo: { owner: 'owner', repo: 'repo' },
+ payload: {
+ pull_request: {
+ number: 42,
+ user: { login: 'octocat' },
+ },
+ },
+ }
+}
+
+test('marks a stale warning as resolved when the pull request closes an issue', async () => {
+ const calls = []
+ const github = {
+ graphql: async () => ({
+ repository: {
+ pullRequest: { closingIssuesReferences: { totalCount: 1 } },
+ },
+ }),
+ paginate: async () => [{ id: 99, body: `${WARNING_MARKER}\nExisting warning` }],
+ rest: {
+ issues: {
+ listComments: () => {},
+ createComment: async () => {
+ throw new Error('comment should not be created')
+ },
+ updateComment: async (input) => calls.push(input),
+ },
+ },
+ }
+
+ await checkPullRequestIssue({
+ github,
+ context: createContext(),
+ core: { info: () => {}, warning: () => {} },
+ })
+
+ assert.deepEqual(calls, [{
+ owner: 'owner',
+ repo: 'repo',
+ comment_id: 99,
+ body: `${RESOLVED_WARNING_MARKER}\n✅ 此 PR 已关联 issue,之前的提醒已自动标记为已解决。`,
+ }])
+})
+
+test('warns and mentions the author when no issue is linked', async () => {
+ const calls = []
+ const github = {
+ graphql: async (query, variables) => {
+ assert.match(query, /closingIssuesReferences/)
+ assert.deepEqual(variables, { owner: 'owner', repo: 'repo', number: 42 })
+ return {
+ repository: {
+ pullRequest: { closingIssuesReferences: { totalCount: 0 } },
+ },
+ }
+ },
+ paginate: async (method, input) => {
+ assert.equal(method, github.rest.issues.listComments)
+ assert.deepEqual(input, {
+ owner: 'owner',
+ repo: 'repo',
+ issue_number: 42,
+ per_page: 100,
+ })
+ return []
+ },
+ rest: {
+ issues: {
+ listComments: () => {},
+ createComment: async (input) => calls.push(input),
+ },
+ },
+ }
+
+ await checkPullRequestIssue({
+ github,
+ context: createContext(),
+ core: { info: () => {}, warning: () => {} },
+ })
+
+ assert.equal(calls.length, 1)
+ assert.deepEqual(calls[0], {
+ owner: 'owner',
+ repo: 'repo',
+ issue_number: 42,
+ body: `${WARNING_MARKER}\n⚠️ @octocat,此 PR 尚未关联 issue。请在 PR 描述中使用 \`Closes #123\` 等关闭关键字,或通过 Development 侧栏关联对应 issue。`,
+ })
+})
+
+test('does not post a duplicate warning', async () => {
+ const github = {
+ graphql: async () => ({
+ repository: {
+ pullRequest: { closingIssuesReferences: { totalCount: 0 } },
+ },
+ }),
+ paginate: async () => [{ body: `${WARNING_MARKER}\nExisting warning` }],
+ rest: {
+ issues: {
+ listComments: () => {},
+ createComment: async () => {
+ throw new Error('duplicate comment should not be created')
+ },
+ updateComment: async () => {
+ throw new Error('resolved comments should not be updated')
+ },
+ },
+ },
+ }
+
+ await checkPullRequestIssue({
+ github,
+ context: createContext(),
+ core: { info: () => {}, warning: () => {} },
+ })
+})
+
+test('posts a new warning when only a resolved warning exists', async () => {
+ const calls = []
+ const github = {
+ graphql: async () => ({
+ repository: {
+ pullRequest: { closingIssuesReferences: { totalCount: 0 } },
+ },
+ }),
+ paginate: async () => [{ body: `${RESOLVED_WARNING_MARKER}\nResolved warning` }],
+ rest: {
+ issues: {
+ listComments: () => {},
+ createComment: async (input) => calls.push(input),
+ updateComment: async () => {
+ throw new Error('resolved comments should not be updated')
+ },
+ },
+ },
+ }
+
+ await checkPullRequestIssue({
+ github,
+ context: createContext(),
+ core: { info: () => {}, warning: () => {} },
+ })
+
+ assert.equal(calls.length, 1)
+})
diff --git a/.github/scripts/triage-issue.cjs b/.github/scripts/triage-issue.cjs
new file mode 100644
index 00000000..64a48f9a
--- /dev/null
+++ b/.github/scripts/triage-issue.cjs
@@ -0,0 +1,132 @@
+const TITLE_RULES = [
+ {
+ pattern: /^\s*(?:\[bug\]|bug(?:fix)?|fix)(?:\([^)]*\))?\s*[::-]/i,
+ label: 'bug',
+ issueType: 'Bug',
+ },
+ {
+ pattern: /^\s*(?:\[feature\]|feat(?:ure)?)(?:\([^)]*\))?\s*[::-]/i,
+ label: 'enhancement',
+ issueType: 'Feature',
+ },
+]
+
+function classifyIssue(title, labelNames = []) {
+ const normalizedLabels = new Set(labelNames.map((label) => label.toLowerCase()))
+
+ if (normalizedLabels.has('bug')) {
+ return { label: null, issueType: 'Bug' }
+ }
+
+ if (normalizedLabels.has('enhancement')) {
+ return { label: null, issueType: 'Feature' }
+ }
+
+ const rule = TITLE_RULES.find(({ pattern }) => pattern.test(title))
+ if (rule) {
+ return {
+ label: normalizedLabels.has(rule.label) ? null : rule.label,
+ issueType: rule.issueType,
+ }
+ }
+
+ return { label: null, issueType: 'Task' }
+}
+
+function selectMilestone(milestones, createdAt) {
+ const createdDate = createdAt.slice(0, 10)
+
+ return [...milestones]
+ .filter((milestone) => !milestone.due_on || milestone.due_on.slice(0, 10) >= createdDate)
+ .sort((left, right) => {
+ if (!left.due_on && !right.due_on) {
+ return Date.parse(right.created_at) - Date.parse(left.created_at)
+ }
+ if (!left.due_on) return 1
+ if (!right.due_on) return -1
+ return Date.parse(left.due_on) - Date.parse(right.due_on)
+ })[0]
+}
+
+async function triageIssue({ github, context, core }) {
+ const issue = context.payload.issue
+ const { owner, repo } = context.repo
+ const labelNames = issue.labels.map((label) => (typeof label === 'string' ? label : label.name))
+ const classification = classifyIssue(issue.title, labelNames)
+ const updates = []
+
+ if (!issue.milestone) {
+ const milestones = await github.paginate(github.rest.issues.listMilestones, {
+ owner,
+ repo,
+ state: 'open',
+ per_page: 100,
+ })
+ const milestone = selectMilestone(milestones, issue.created_at)
+
+ if (milestone) {
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: issue.number,
+ milestone: milestone.number,
+ })
+ updates.push(`milestone=${milestone.title}`)
+ } else {
+ core.info('No current open milestone found; leaving milestone unchanged.')
+ }
+ }
+
+ if (classification.label) {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: issue.number,
+ labels: [classification.label],
+ })
+ updates.push(`label=${classification.label}`)
+ }
+
+ const metadata = await github.graphql(
+ `query IssueTriageMetadata($owner: String!, $repo: String!, $number: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $number) {
+ id
+ issueType { id name }
+ }
+ issueTypes(first: 20) {
+ nodes { id name isEnabled }
+ }
+ }
+ }`,
+ { owner, repo, number: issue.number },
+ )
+
+ const repository = metadata.repository
+ if (!repository.issue.issueType) {
+ const desiredType = repository.issueTypes.nodes.find(
+ (issueType) =>
+ issueType.isEnabled && issueType.name.toLowerCase() === classification.issueType.toLowerCase(),
+ )
+
+ if (desiredType) {
+ await github.graphql(
+ `mutation SetIssueType($issueId: ID!, $issueTypeId: ID!) {
+ updateIssue(input: { id: $issueId, issueTypeId: $issueTypeId }) {
+ issue { id }
+ }
+ }`,
+ { issueId: repository.issue.id, issueTypeId: desiredType.id },
+ )
+ updates.push(`type=${desiredType.name}`)
+ } else {
+ core.warning(`Enabled issue type "${classification.issueType}" is unavailable.`)
+ }
+ }
+
+ core.info(updates.length > 0 ? `Applied ${updates.join(', ')}.` : 'Issue metadata is already set.')
+}
+
+module.exports = triageIssue
+module.exports.classifyIssue = classifyIssue
+module.exports.selectMilestone = selectMilestone
diff --git a/.github/scripts/triage-issue.test.cjs b/.github/scripts/triage-issue.test.cjs
new file mode 100644
index 00000000..b08783a2
--- /dev/null
+++ b/.github/scripts/triage-issue.test.cjs
@@ -0,0 +1,149 @@
+const assert = require('node:assert/strict')
+const test = require('node:test')
+
+const triageIssue = require('./triage-issue.cjs')
+const { classifyIssue, selectMilestone } = triageIssue
+
+test('classifies conventional bug and feature titles', () => {
+ assert.deepEqual(classifyIssue('[Bug]: broken login'), { label: 'bug', issueType: 'Bug' })
+ assert.deepEqual(classifyIssue('feat(editor): add onion skinning'), {
+ label: 'enhancement',
+ issueType: 'Feature',
+ })
+})
+
+test('uses existing classification labels without adding another label', () => {
+ assert.deepEqual(classifyIssue('login is broken', ['P0', 'bug']), {
+ label: null,
+ issueType: 'Bug',
+ })
+})
+
+test('adds the classification label while preserving unrelated labels', () => {
+ assert.deepEqual(classifyIssue('[Bug]: broken login', ['P0', 'MiniSpec']), {
+ label: 'bug',
+ issueType: 'Bug',
+ })
+})
+
+test('defaults unclassified issues to Task', () => {
+ assert.deepEqual(classifyIssue('issue 关联 milestone'), { label: null, issueType: 'Task' })
+})
+
+test('selects the nearest open milestone whose due date includes the issue date', () => {
+ const milestone = selectMilestone(
+ [
+ { number: 5, due_on: '2026-09-01T00:00:00Z', created_at: '2026-08-10T00:00:00Z' },
+ { number: 4, due_on: '2026-08-14T00:00:00Z', created_at: '2026-08-04T00:00:00Z' },
+ { number: 3, due_on: '2026-08-01T00:00:00Z', created_at: '2026-07-01T00:00:00Z' },
+ ],
+ '2026-08-13T12:00:00Z',
+ )
+
+ assert.equal(milestone.number, 4)
+})
+
+test('leaves fields already associated untouched', async () => {
+ const calls = []
+ const github = {
+ paginate: async () => {
+ throw new Error('milestones should not be queried')
+ },
+ rest: {
+ issues: {
+ listMilestones: () => {},
+ update: async () => calls.push('update'),
+ addLabels: async () => calls.push('addLabels'),
+ },
+ },
+ graphql: async () => ({
+ repository: {
+ issue: { id: 'issue-id', issueType: { id: 'type-id', name: 'Bug' } },
+ issueTypes: { nodes: [] },
+ },
+ }),
+ }
+
+ await triageIssue({
+ github,
+ context: {
+ repo: { owner: 'owner', repo: 'repo' },
+ payload: {
+ issue: {
+ number: 1,
+ title: '[Bug]: broken',
+ created_at: '2026-08-13T00:00:00Z',
+ labels: [{ name: 'bug' }],
+ milestone: { number: 4 },
+ },
+ },
+ },
+ core: { info: () => {}, warning: () => {} },
+ })
+
+ assert.deepEqual(calls, [])
+})
+
+test('associates every missing field for a classified issue', async () => {
+ const calls = []
+ const github = {
+ paginate: async () => [
+ {
+ number: 4,
+ title: 'MS3',
+ due_on: '2026-08-14T00:00:00Z',
+ created_at: '2026-08-04T00:00:00Z',
+ },
+ ],
+ rest: {
+ issues: {
+ listMilestones: () => {},
+ update: async (input) => calls.push(['update', input]),
+ addLabels: async (input) => calls.push(['addLabels', input]),
+ },
+ },
+ graphql: async (query, variables) => {
+ if (query.includes('query IssueTriageMetadata')) {
+ return {
+ repository: {
+ issue: { id: 'issue-id', issueType: null },
+ issueTypes: {
+ nodes: [{ id: 'bug-type-id', name: 'Bug', isEnabled: true }],
+ },
+ },
+ }
+ }
+ calls.push(['setIssueType', variables])
+ return { updateIssue: { issue: { id: 'issue-id' } } }
+ },
+ }
+
+ await triageIssue({
+ github,
+ context: {
+ repo: { owner: 'owner', repo: 'repo' },
+ payload: {
+ issue: {
+ number: 7,
+ title: '[Bug]: broken',
+ created_at: '2026-08-13T00:00:00Z',
+ labels: [],
+ milestone: null,
+ },
+ },
+ },
+ core: { info: () => {}, warning: () => {} },
+ })
+
+ assert.deepEqual(calls, [
+ [
+ 'update',
+ { owner: 'owner', repo: 'repo', issue_number: 7, milestone: 4 },
+ ],
+ [
+ 'addLabels',
+ { owner: 'owner', repo: 'repo', issue_number: 7, labels: ['bug'] },
+ ],
+ ['setIssueType', { issueId: 'issue-id', issueTypeId: 'bug-type-id' }],
+ ])
+})
diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index 04e12538..fbb70e6d 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -38,8 +38,22 @@ jobs:
- name: Ruff
run: uv run ruff check .
+ - name: OpenAPI contract drift
+ run: |
+ uv run python -m scripts.export_openapi
+ git diff --exit-code -- ../openapi.json
+
- name: Import-linter (分层契约)
run: uv run lint-imports
- name: Pytest
- run: uv run pytest -q
+ run: uv run pytest -q --cov=packages --cov-report=term-missing --cov-report=xml:coverage.xml
+
+ - name: Upload coverage to Codecov
+ if: always()
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: backend/coverage.xml
+ flags: backend
+ fail_ci_if_error: false
diff --git a/.github/workflows/naming.yml b/.github/workflows/naming.yml
deleted file mode 100644
index 8da23a95..00000000
--- a/.github/workflows/naming.yml
+++ /dev/null
@@ -1,109 +0,0 @@
-name: Naming Convention
-
-on:
- push:
- pull_request:
-
-# 与 backend.yml 的 concurrency group 区分,避免两个 workflow 互相取消
-concurrency:
- group: naming-${{ github.ref }}
- cancel-in-progress: true
-
-# 开源项目最小权限
-permissions:
- contents: read
-
-jobs:
- # ── 命名规范门禁 ─────────────────────────────────────────────
- # 分支名:/,如 feat/login、fix/redirect、docs/api
- # 提交信息:Conventional Commits,()?: <描述>
- # 长期分支(main/develop/release/*)、FennoAI 托管分支与无斜杠的扁平分支名豁免;
- # 上游 squash-merge 提交(结尾 (#NN))豁免--贡献者无法改写上游历史。
- validate-branch:
- name: Branch name
- runs-on: ubuntu-latest
- steps:
- - name: Check branch name
- env:
- # 用 env 传参,避免 ${{ }} 直接插值进 shell 造成命令注入
- EVENT_NAME: ${{ github.event_name }}
- HEAD_REF: ${{ github.head_ref }}
- REF_NAME: ${{ github.ref_name }}
- run: |
- set -uo pipefail
- if [ "$EVENT_NAME" = "pull_request" ]; then
- branch="$HEAD_REF"
- else
- branch="$REF_NAME"
- fi
- echo "Branch: $branch"
- # 长期分支豁免
- case "$branch" in
- main|master|develop) echo "豁免(长期分支): $branch"; exit 0 ;;
- release/*|hotfix/*) echo "豁免(release/hotfix): $branch"; exit 0 ;;
- fennoai/*) echo "豁免(FennoAI 托管分支): $branch"; exit 0 ;;
- esac
- # 无斜杠的扁平分支名豁免(如 backend-architecture、upstream-sync)
- case "$branch" in
- */*) ;;
- *) echo "豁免(扁平名): $branch"; exit 0 ;;
- esac
- PATTERN='^(feat|fix|docs|doc|chore|refactor|test|style|perf|ci|build|revert|explore|wip)/.+'
- if printf '%s' "$branch" | grep -Eq "$PATTERN"; then
- echo "OK: '$branch'"
- exit 0
- fi
- echo "::error::分支 '$branch' 不符合 / 规范。"
- echo "允许的 type: feat fix docs doc chore refactor test style perf ci build revert explore wip"
- echo "示例: feat/backend-architecture、fix/login-redirect、docs/api-reference"
- exit 1
-
- validate-commits:
- name: Commit messages
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- with:
- fetch-depth: 0 # 需要完整历史来算 merge-base
- - name: Check commit messages
- env:
- EVENT_NAME: ${{ github.event_name }}
- BASE_REF_PR: ${{ github.base_ref }}
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- run: |
- set -euo pipefail
- if [ "$EVENT_NAME" = "pull_request" ]; then
- BASE_REF="$BASE_REF_PR"
- else
- BASE_REF="$DEFAULT_BRANCH"
- fi
- echo "Base ref: $BASE_REF"
- git fetch --no-tags origin "$BASE_REF"
- BASE=$(git merge-base "origin/$BASE_REF" HEAD)
- echo "检查范围: $BASE..HEAD(merge 提交与上游 squash (#NN) 豁免)"
- TYPE='(feat|fix|docs|chore|refactor|test|style|perf|ci|build|revert)'
- SCOPE='(\([^)]+\))?'
- PATTERN="^${TYPE}${SCOPE}!?: .+"
- fail=0; total=0
- for sha in $(git rev-list --no-merges "$BASE..HEAD"); do
- total=$((total + 1))
- subject=$(git log -1 --format=%s "$sha")
- if printf '%s' "$subject" | grep -Eq '\(#[0-9]+\)$'; then
- echo "skip(上游 squash): $sha '$subject'"
- continue
- fi
- if printf '%s' "$subject" | grep -Eq "$PATTERN"; then
- echo "ok: $sha '$subject'"
- else
- echo "::error::commit $sha 不符合 Conventional Commits: '$subject'"
- fail=1
- fi
- done
- echo "共检查 $total 个非 merge 提交"
- if [ "$fail" -ne 0 ]; then
- echo ""
- echo "格式: ()?: <简要描述>"
- echo "type: feat fix docs chore refactor test style perf ci build revert"
- echo "示例: feat: 添加健康检查路由 fix(parser): 修复空指针"
- exit 1
- fi
diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml
new file mode 100644
index 00000000..8c88836d
--- /dev/null
+++ b/.github/workflows/triage.yml
@@ -0,0 +1,47 @@
+name: Triage issues and pull requests
+
+on:
+ issues:
+ types: [opened]
+ pull_request_target:
+ types: [opened, edited, reopened, synchronize, ready_for_review]
+
+permissions: {}
+
+jobs:
+ associate-issue-metadata:
+ name: Associate milestone, label, and type
+ if: github.event_name == 'issues'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Triage issue
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const triageIssue = require('./.github/scripts/triage-issue.cjs')
+ await triageIssue({ github, context, core })
+
+ check-pull-request-issue:
+ name: Check linked issue
+ if: github.event_name == 'pull_request_target'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Warn about missing issue
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const checkPullRequestIssue = require('./.github/scripts/check-pr-issue.cjs')
+ await checkPullRequestIssue({ github, context, core })
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..eb7ecfe7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,59 @@
+# 参与贡献 / Contributing
+
+欢迎参与 Windup 的开发。项目按 `Proposal → Issue → 分支 → PR → Review → 合并` 推进:所有改动从 Issue 出发,代码经 PR 合入 main。第一次参与,按本文档顺序读下来即可跑通全流程。
+
+## 开始之前:Fork 与上游同步
+
+协作采用 Fork + PR:fork 主仓库到个人账号,开发分支只存在于个人 fork,向主仓库提 PR。先把仓库拉到本地并连上上游:
+
+```bash
+git clone git@github.com:<你的账号>/Windup.git
+cd Windup
+git remote add upstream git@github.com:1024XEngineer/Windup.git
+```
+
+开新分支、提 PR 之前,先同步上游再 rebase:
+
+```bash
+git fetch upstream
+git checkout main
+git rebase upstream/main
+git checkout -b <分支名>
+```
+
+## 本地开发与联调
+
+前后端启动方式见 README「本地开发」;后端依赖 Postgres 与 Redis,用根目录的 docker-compose.yml 起依赖容器。
+
+- 前端通过 `VITE_API_BASE_URL` 指向后端;本地联调时指向本地后端,**禁止直连生产 API**。
+- 后端 PR 合并后,按上一节的方式同步上游,再继续联调。
+- 接口疑问优先查根目录 `openapi.json` 与后端 `/docs`,并在对应 PR / Issue 中讨论。
+
+## 提 Issue
+
+- 每个 Issue 写清背景、目标和验收标准,禁止空泛标题。
+- 新 Issue 由自动化 triage 挂 milestone 和 label;请为每个 Issue 指定 owner(assignee)——没有 milestone 的 Issue 不在计划内。
+- 关闭 Issue 时注明原因:已被 PR 解决(注明 PR 号)/ 被其他 Issue 取代(注明替代者)/ 组内确认不再需要。
+- 每个 Milestone 结束时归置遗留 Issue:已完成的关闭;划入下个 Milestone 的挂过去并指定 owner;其余打 `Proposal-NoPlan`。
+
+## 提 PR
+
+- 每个 PR 关联对应 Issue,改动范围与 Issue 一致,不夹带无关改动。
+- main 开启了分支保护:必须走 PR、必须获得 approve、CI 必须通过,没有捷径。
+- PR 合并由 @nighca / @minorcell / @huyanxius 负责,提交后找其中一位即可。
+- **禁止把未经 review / 未合并的代码部署到生产环境。**
+
+## 接口契约
+
+- 前后端以根目录 `openapi.json` 为唯一契约源,由代码自动生成,禁止手写。
+- 后端 PR 变更接口(路由、参数、模型、描述)时,在 backend/ 下运行 `uv run python -m scripts.export_openapi` 重新生成并提交;CI 会校验漂移,不一致即失败。
+
+## 发版
+
+- Tag 统一使用 `vX.Y.Z`;每个 Milestone 结束发一次 Release,描述列清本轮交付。
+- 生产部署必须先发版本:打 Tag → 创建 Release(写清变更内容)→ 部署。**任何部署只认 Release。**
+- 生产部署暂为手动,由发布人唯一执行;CD 自动化另立 Issue 跟进。
+
+## 规范本身
+
+本文档经 [#334](https://github.com/1024XEngineer/Windup/issues/334) 团队确认后生效。需要修改时,先开 Issue 讨论,达成一致后更新本文档。
diff --git a/README.md b/README.md
index d4190536..ae6e3f01 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,43 @@
面向国产小游戏开发者的 2D 角色动态素材生成与资产工作台
-交付的是资产,而不是图片。
+让你的角色,真正登场。
-Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧质检、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。
+
+ 在线使用
+ ·
+ 问题与建议
+ ·
+ OpenAPI
+
+
+
+
+
+
+
+
+
+Windup 已上线,现已开放注册。
+
+
+
+
+
+Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧审核、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。
+
+## 当前能力 / What You Can Do
+
+| 能力 | 当前可用内容 |
+| --- | --- |
+| 项目与资产库 | 管理项目约束、角色、造型、动作与帧,继续扩展已有角色资产 |
+| Quick Start | 用自然语言描述角色和动作,由系统建立标准制作流程 |
+| Workflow Editor | 在真实节点画布中确认角色母版、动作首帧、生成方式、完整动画与审核状态 |
+| 角色与动作生成 | 接入真实生成任务,保存任务状态与产物,支持失败恢复与结果追溯 |
+| 审核与局部返工 | 对候选图和动作结果进行确认,在具体节点重试而不必重做整条流程 |
+| Playtest 与导出 | 在浏览器中预览动作,并导出透明 PNG、Sprite Sheet、动画 JSON 与 ZIP 资源包 |
+
+三渲二、多方向资产和更多引擎适配仍在推进。相关基础能力进入仓库不等于已进入在线产品主流程;当前进度以 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues) 为准。
## 产品链路 / Product Workflow
@@ -18,12 +52,12 @@ Windup 面向缺少美术产能的个人开发者和小型团队,把角色构
新角色:文字描述 / 参考图 → 项目约束 → 角色母版
已有角色:从资产库继续生产 ─────────────┘
↓
- 动作序列帧 → 逐帧审核 / 局部重生成
+ 动作序列帧 → 审核 / 局部重生成
↓
Playtest 试玩 → PNG / Sprite Sheet / 元数据 → 游戏引擎
```
-Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体帧或节点,已通过的结果继续保留。
+Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体节点,已经确认的结果继续保留。
## 核心对象 / Core Concepts
@@ -31,42 +65,31 @@ Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定
| --- | --- |
| `Project` | 统一管理题材、美术风格、视角与精灵尺寸等项目级约束 |
| `Character` | 角色资产本体;造型、动作实例与帧属于它的资产树 |
-| `ActionTemplate` | 可在不同角色间复用的动作规格与生产配方 |
| `Generation` | 一次生成任务及其输入、状态和结果,用于恢复与追溯 |
-| `WorkflowRun` | 一次前端制作流程的运行记录,连接生成、确认、回退与导出 |
-
-产品提供两种入口:`Quick Start` 用自然语言建立标准生产流程;`Workflow Editor` 在系统预置的成熟管线上追加动作分支、微调参数和局部返工。两者共用同一套流程状态和质量门禁,分别服务快速创建与精细控制。
-
-## 当前阶段 / Project Status
+| `WorkflowRun` | 一次制作流程的持久化运行记录,连接生成、确认、回退与导出 |
-MS2 已完成 Windup 的产品 MVP,验证了角色资产生产的核心链路。MS3 的重点从“完成一次生成”转向“持续完善已有角色资产”:用户可以从资产库回到已有角色,为它补充动作、重做有问题的分支,并保留未受影响的资产。
-
-| 状态 | 内容 |
-| --- | --- |
-| MS2 产出 | 完成产品 MVP,跑通并验证角色资产生产的核心体验 |
-| MS3 产品主线 | 已有角色补动作;工作流采用固定成熟管线,通过卡片加号追加分支,支持参数微调与局部重跑 |
-| MS3 工程重点 | 持久化 `WorkflowRun` 并关联角色,串起工作流编辑、产物审核、节点回退与 Playtest |
-| 后续探索 | Quick Start Agent、3D 动作生成路线、多视角资产与项目级导出 |
-
-项目进度见 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues)。
+`Quick Start` 与 `Workflow Editor` 是同一套流程状态的两种入口:前者用于快速建立标准流程,后者用于查看节点依赖、调整生成方式和处理局部返工。
## 技术栈 / Tech Stack
- 前端:React 19、TypeScript 6、Vite 8、Tailwind CSS 4、Vitest
- 后端:Python 3.12、FastAPI、Pydantic、SQLAlchemy、uv workspace
+- 基础设施:PostgreSQL、Redis、Docker Compose、Nginx
- 工程约束:GitHub Actions、Ruff、Pytest、Import Linter、oxlint、oxfmt
## 本地开发 / Local Development
-前端支持 Node.js `^20.19.0`、`^22.12.0` 或 `>=24.0.0`;CI 使用 Node.js 24:
+需要 Node.js 24、Python 3.12、[uv](https://docs.astral.sh/uv/)、PostgreSQL 与 Redis。
+
+先准备本地配置和依赖服务:
```bash
-cd frontend
-npm ci
-npm run dev
+cp .env.example .env
+# 在 .env 中配置 POSTGRES_PASSWORD、JWT_SECRET(至少 32 字符)及所需服务凭据
+docker compose up -d postgres redis
```
-后端使用 Python 3.12 和 [uv](https://docs.astral.sh/uv/):
+启动后端:
```bash
cd backend
@@ -74,40 +97,55 @@ uv sync --frozen
uv run uvicorn windup_app.bootstrap.app:create_app --factory --reload
```
+另开一个终端启动前端:
+
+```bash
+cd frontend
+npm ci
+npm run dev
+```
+
+前端开发服务器默认访问 `http://localhost:5173`,后端健康检查为 `http://localhost:8000/health`。前端需要指向其他后端时,通过构建期变量 `VITE_API_BASE_URL` 配置。
+
## 质量检查 / Quality Checks
+以下命令与 GitHub Actions 的主要检查保持一致:
+
```bash
# frontend/
npm run format:check
npm run lint
npm run typecheck
-npm run test
+npm run test:coverage
npm run build
# backend/
uv run ruff check .
+uv run python -m scripts.export_openapi
uv run lint-imports
-uv run pytest -q
+uv run pytest -q --cov=packages
```
## 仓库结构 / Repository Structure
```text
Windup/
-├── frontend/ # React 前端、页面与制作流程
-├── backend/ # Python 工作区、领域服务与 API
-└── README.md
+├── frontend/ # React 前端、产品页面与制作流程
+├── backend/ # FastAPI 应用、领域服务、生成引擎与基础设施
+├── openapi.json # 从后端自动生成的接口契约
+└── docker-compose.yml # PostgreSQL、Redis、后端与前端构建任务
```
## 相关文档 / Documentation
+- [在线产品](https://windup.xin)
- [Windup 产品策划案](https://github.com/1024XEngineer/Windup/issues/37)
- [核心流程与工作流](https://github.com/1024XEngineer/Windup/issues/25)
-- [前后端 API 契约差异](frontend/API_CONTRACT.md)
+- [OpenAPI 接口契约](openapi.json)
## 参与贡献 / Contributing
-问题、需求和实验记录统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。
+Bug、需求和实验建议统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。
项目的维护与历史贡献见 [Contributors](https://github.com/1024XEngineer/Windup/graphs/contributors)。
diff --git a/backend/packages/ai_engine/pyproject.toml b/backend/packages/ai_engine/pyproject.toml
index bb279425..c8e4b7f8 100644
--- a/backend/packages/ai_engine/pyproject.toml
+++ b/backend/packages/ai_engine/pyproject.toml
@@ -10,6 +10,9 @@ dependencies = [
"langchain-core>=0.3",
"pillow>=10.4",
"numpy>=1.26",
+ "imageio>=2.36",
+ "imageio-ffmpeg>=0.6",
+ "av>=14.0", # imageio pyav 后端(视频抽帧)
# "rembg", # 抠图(按需启用)
]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py b/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py
new file mode 100644
index 00000000..acec47d6
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py
@@ -0,0 +1,26 @@
+"""PNG bytes ↔ PIL 的唯一转换口。
+
+管线内部按 ``PIL.Image`` 处理,跨模块边界(strategy → generator → ports 出参)按 PNG
+bytes 传递。这对转换此前在 ``strategy.concrete`` 与 ``impl.character_generator`` 各写
+了一份,收成一处 —— 编码参数(如是否强制 RGBA)一旦分叉,会在"某些帧丢了 alpha"这类
+只在画面上体现、不报错的地方出问题。
+"""
+from __future__ import annotations
+
+import io
+
+from PIL import Image
+
+__all__ = ["to_png", "from_png"]
+
+
+def to_png(img: Image.Image) -> bytes:
+ """PIL → PNG bytes。统一转 RGBA:下游脚线对齐靠 alpha 求包围盒。"""
+ buf = io.BytesIO()
+ img.convert("RGBA").save(buf, "PNG")
+ return buf.getvalue()
+
+
+def from_png(png: bytes) -> Image.Image:
+ """PNG bytes → RGBA 图。"""
+ return Image.open(io.BytesIO(png)).convert("RGBA")
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_subject.py b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py
new file mode 100644
index 00000000..ee6c0852
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py
@@ -0,0 +1,76 @@
+"""「哪些像素是主体」的唯一定义(母版预检 / 脚线 / 补边背景色共用)。
+
+此前这套判据有两份:``master_prep._bg_color`` 取四角中位色补边,
+``slicing.oneshot._subject_rows`` 用同一套四角中位色 + 容差找脚线。入口预检
+(:mod:`.master_check`)必须与下游用**同一个**主体定义 —— 判据一旦分叉就会出现
+"预检说有主体、下游找不到主体"这种只在画面上体现、不报错的分歧,和
+:mod:`._imgio` / :mod:`.slicing._frames` 当初被收拢是同一个理由。
+
+判据本身:有真 alpha(存在低于阈值的像素)就用 alpha;整幅不透明(原始视频帧 /
+RGB 母版)则按四角中位背景色的差值。**这是颜色启发式,不是抠图模型** ——
+背景带渐变、或角色与背景同色时判不准,见 :func:`subject_mask`。
+"""
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = ["bbox_of", "bg_color", "subject_bbox", "subject_mask"]
+
+ALPHA_THR = 128 # alpha 高于此值算不透明(与 postprocess.pack 求包围盒的口径一致)
+BG_TOL = 60 # 与背景色的 RGB 绝对差之和,超过才算主体
+
+
+def _bg_median(rgb: np.ndarray) -> np.ndarray:
+ """四角中位色(float)。母版 / 视频帧通常是纯色底,四角取中位比取均值抗单角污染。"""
+ corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]])
+ return np.median(corners, axis=0)
+
+
+def bg_color(img: Image.Image) -> tuple[int, int, int]:
+ """背景色(取整),给补边用。"""
+ rgb = np.asarray(img.convert("RGB"))
+ return tuple(int(v) for v in _bg_median(rgb))
+
+
+def subject_mask(
+ img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL
+) -> np.ndarray:
+ """主体像素的二维布尔掩码。
+
+ 必须兼容**不透明**输入:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把
+ 整幅当主体、脚线恒定,腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。
+
+ 判不准的已知情形(调用方别当成抠图):背景有渐变 → 整幅都超容差,掩码≈全 True;
+ 角色主色与背景色接近 → 那部分身体被判成背景。要真分割请走 MatteProvider。
+ """
+ arr = np.asarray(img.convert("RGBA"))
+ alpha = arr[:, :, 3]
+ if not alpha.min() > alpha_thr: # 存在透明像素 = 有真 alpha,直接用
+ return alpha > alpha_thr
+ rgb = arr[:, :, :3].astype(np.int16)
+ return np.abs(rgb - _bg_median(rgb)).sum(axis=2) > bg_tol
+
+
+def bbox_of(mask: np.ndarray) -> tuple[tuple[int, int, int, int], int] | None:
+ """已有掩码时的包围盒 + 像素数。
+
+ 单独拆出来是为了让**同时要掩码和包围盒**的调用方(母版预检要在掩码上再数横向连通段)
+ 只算一次掩码;若让它自己从掩码求包围盒,那份口径就与本模块分叉了。
+ """
+ ys, xs = np.where(mask)
+ if not len(ys):
+ return None
+ box = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
+ return box, int(mask.sum())
+
+
+def subject_bbox(
+ img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL
+) -> tuple[tuple[int, int, int, int], int] | None:
+ """主体包围盒 ``(x0, y0, x1, y1)``(半开,同 PIL crop)+ 主体像素数;无主体返回 None。
+
+ 包围盒与像素数一起返回:两者判的不是同一件事 —— 包围盒管"主体有多大",
+ 像素数管"包围盒里是不是真有东西"(散落的几粒噪点能把包围盒撑满整幅)。
+ """
+ return bbox_of(subject_mask(img, alpha_thr, bg_tol))
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/business/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/graph/tools/external/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/impl/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py
new file mode 100644
index 00000000..12f538d2
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py
@@ -0,0 +1,6 @@
+"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。"""
+
+from .character_generator import CharacterGenerator
+from .character_namer import LangChainCharacterNamer
+
+__all__ = ["CharacterGenerator", "LangChainCharacterNamer"]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py
new file mode 100644
index 00000000..d8ed322b
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py
@@ -0,0 +1,261 @@
+"""CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。
+
+这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。
+串联:母版预检(可拒绝)→ 选路线(ROUTE_MATRIX)→ strategy.derive 出帧 →
+最后一公里(脚线对齐)→ 量交付成色 → GeneratedAction。
+
+两头各有一道闸,方向相反:进门那道(master_check)在**花钱之前**挡住不可能生成好的
+输入;出门那几道(空帧 / 帧数 / 成色)在钱已经花完之后,挡住"看起来成功的错产物"。
+
+MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储——
+上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。
+"""
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+from windup_common.models import ActionSpec, CharacterCard, GenRoute
+
+from windup_ai_engine._imgio import from_png as _img
+from windup_ai_engine._imgio import to_png as _png
+from windup_ai_engine.master_check import check_master
+from windup_ai_engine.ports import (
+ ActionQuality,
+ CharacterGeneratorPort,
+ GeneratedAction,
+ ProgressPort,
+)
+from windup_ai_engine.postprocess import align_bottom_center, frame_durations
+from windup_ai_engine.slicing import (
+ dead_frame_indices,
+ limb_motion,
+ loop_seam,
+ motion_scale,
+)
+from windup_ai_engine.strategy.base import (
+ ROUTE_MATRIX,
+ DerivationStrategy,
+ is_cyclic,
+)
+
+# ── 进度刻度:整条生产线只有一个 total ────────────────────────────────────────
+#
+# 这里曾经是两套刻度:本类按 i/4 报,而中间夹着的 strategy.derive 按 i/3 报到**同一个**
+# ProgressPort 上。消费方按 i/total 画进度条就会看到它倒退两次(25.0% → 0.0%、
+# 66.7% → 50.0%,2026-08-12 实跑确认)。一个量有两个真相源,取哪个看消费方心情——
+# 与本分片删掉 fps / loop / palette 是同一条理由。
+#
+# 现在:本类独占全局刻度,strategy 的子进度由 _BandProgress 线性映射进 derive 区间。
+# 刻度取 10 而不是 5,是为了给 derive 段留出中间刻度 —— 否则子进度只能全部落在同一格,
+# 虽不倒退但也不动。
+_TOTAL = 10
+_TICK_PRECHECK = 0
+_TICK_ROUTE = 1
+_DERIVE_FROM, _DERIVE_TO = 2, 7 # strategy 的 0..sub_total 映射到 [2, 7]
+_TICK_LASTMILE = 8
+_TICK_PACKAGE = 9
+
+
+class _BandProgress(ProgressPort):
+ """把子组件自报的 ``(i, sub_total)`` 线性映射进外层刻度的 ``[lo, hi]`` 区间。
+
+ 为什么由适配器换算,而不是让 strategy 直接按全局刻度报:strategy 是可插拔件,
+ 步数各路线不同(视频路线 3 步,逐帧路线未实现、步数必然不同),让它知道外层有几步
+ 就把它钉死在 generator 当前的步骤布局上。也不要求 strategy **声明**自己有几步——
+ 那又是一个"声明值 vs 实际值"的第二真相源,声明错了没人拦。
+
+ 只读 strategy 每次调用时自报的 ``total``,故 strategy 侧零改动。
+ """
+
+ __slots__ = ("_inner", "_lo", "_hi")
+
+ def __init__(self, inner: ProgressPort, lo: int, hi: int) -> None:
+ self._inner = inner
+ self._lo = lo
+ self._hi = hi
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ # total<=0 时按 0 处理:子组件报了个没法换算的刻度,不能因此炸掉一条已经花过钱的
+ # 生产线,退化成"停在区间起点"即可(仍然单调)。
+ frac = 0.0 if total <= 0 else min(1.0, max(0.0, i / total))
+ self._inner.step(stage, self._lo + int((self._hi - self._lo) * frac), _TOTAL, note)
+
+
+class CharacterGenerator(CharacterGeneratorPort):
+ """由 bootstrap 注入 {GenRoute: DerivationStrategy} 装配表。"""
+
+ def __init__(self, strategies: dict[GenRoute, DerivationStrategy]) -> None:
+ self._by_route = strategies
+
+ def generate(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ canvas: tuple[int, int] | None = None,
+ ) -> GeneratedAction:
+ # ① 入口预检 —— 唯一一道在**花钱之前**的闸,故排在选路线之前。
+ # 之前这里什么都不判:一张"人物在画板前作画"的图请求 walk,全程无一处报错,
+ # 16 帧构图完整的错角色出完、钱花完(2026-08-07 实测)。预检拦不住"内容画错"
+ # (那要视觉模型),但坏图 / 空图 / 极端比例这几类不必等到出帧才发现。
+ # 预检与出帧必须用**同一个** canvas:比例上限是由交付画布几何推出来的,
+ # 传一个、出另一个就等于预检按方形判、出帧按非方出(见 master_check)。
+ facts = check_master(master, canvas)
+ progress.step("precheck", _TICK_PRECHECK, _TOTAL, facts.note())
+
+ # ② 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸,
+ # 不要让"看着成功、内容是空"的结果流到 server 去落库。
+ #
+ # 三渲二**不经过这里**:它由 server 读 DB 判断该造型有没有 3D 资产后直接调
+ # generate_rendered(#122)。ROUTE_MATRIX 因此仍然只映射"由动作物理性质唯一
+ # 决定"的那两条,它的隐含前提不被破坏(见 strategy.base 模块注释)。
+ route = ROUTE_MATRIX[action.action]
+ # .value 而不是枚举本身:Python 3.11+ 的 str-mixin 枚举 __format__ 会给出
+ # "ActionType.WALK",这串字最终是用户看到的进度文案(3.12.13 实测)。
+ progress.step("route", _TICK_ROUTE, _TOTAL, f"{action.action.value} → {route.value}")
+ strategy = self._pick(route, action)
+
+ # ③ 生成帧(交给 strategy —— 串联)
+ frames = strategy.derive(
+ card, action, master, _BandProgress(progress, _DERIVE_FROM, _DERIVE_TO)
+ )
+ return self._finish(frames, action, route, progress, canvas)
+
+ def generate_rendered(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ rigged_model: bytes,
+ progress: ProgressPort,
+ canvas: tuple[int, int] | None = None,
+ ) -> GeneratedAction:
+ """三渲二入口。见 ``ports.CharacterGeneratorPort.generate_rendered``。
+
+ **没有母版预检那一道**,不是漏了:``check_master`` 判的是尺寸 / 比例 / 空图,
+ 而这条路线的输入是 3D 模型,那些判据一条都不适用。模型自身的预检在 server
+ 建资产那一步做 —— 那才是花钱的地方,这里已经不花钱了。
+ """
+ route = GenRoute.RENDER_3D
+ progress.step("route", _TICK_ROUTE, _TOTAL, f"{action.action.value} → {route.value}")
+ strategy = self._pick(route, action)
+ frames = strategy.derive(
+ card, action, rigged_model, _BandProgress(progress, _DERIVE_FROM, _DERIVE_TO)
+ )
+ return self._finish(frames, action, route, progress, canvas)
+
+ # ── 两个入口共用的部分 ────────────────────────────────────────────────
+ def _pick(self, route: GenRoute, action: ActionSpec) -> DerivationStrategy:
+ strategy = self._by_route.get(route)
+ if strategy is None:
+ raise NotImplementedError(
+ f"动作 {action.action.value} 分流到 {route.value},但未注入该路线的 strategy。"
+ f"已装配:{sorted(r.value for r in self._by_route)}。"
+ )
+ return strategy
+
+ def _finish(
+ self,
+ frames: list[bytes],
+ action: ActionSpec,
+ route: GenRoute,
+ progress: ProgressPort,
+ canvas: tuple[int, int] | None,
+ ) -> GeneratedAction:
+ """出帧之后的公共尾段:帧数对账 → 脚线对齐 → 量成色 → 出参。
+
+ 两个入口共用同一份,不是为了少写几行:这几道闸是**对所有路线**的约束,
+ 复制一份就会有一天只在一条路线上被改。
+ """
+ # 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的
+ # 承诺,而抽帧那两个函数都会**静默少给**:slicing.pick_cycle / pick_oneshot 在
+ # `len(dense) <= n`(或动作区间比 n 短)时 return frames/span,长度不足且不报错
+ # (2026-08-08 读码复核)。少给的后果不是崩溃而是"短一截的动作":时长表由
+ # frame_durations(…, len(frames)) 现算,长度自洽,server 看不出异常,用户拿到
+ # 一段步子没走完的循环。故在此对账 —— 钱已经花了,但至少不让错产物流下去。
+ if len(frames) != action.n_frames:
+ raise ValueError(
+ f"{route.value} 要 {action.n_frames} 帧,实际产出 {len(frames)} 帧。"
+ "抽帧源帧数不足(i2v 视频太短 / 动作区间过窄)时会静默少给,"
+ "请调小 n_frames 或加长视频。"
+ )
+
+ # 最后一公里:脚线对齐成原地序列帧(直接对齐到调用方要的画布尺寸)
+ aligned = self._lastmile(frames, progress, canvas)
+
+ # 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 /
+ # 对齐都会改像素,在中间任何一步量出来的数都描述不了交付物。
+ quality = self._assess(aligned, action)
+
+ progress.step(
+ "package", _TICK_PACKAGE, _TOTAL,
+ f"{len(aligned)} 帧 + 逐帧时长(动量 {quality.motion_scale:.2f},"
+ f"死帧 {len(quality.dead_frames)}/{len(aligned)})",
+ )
+ return GeneratedAction(
+ frames=[_png(im) for im in aligned],
+ durations=frame_durations(action.action.value, len(aligned)),
+ quality=quality,
+ )
+
+ def _assess(self, frames: list[Image.Image], action: ActionSpec) -> ActionQuality:
+ """量交付帧的成色。这些数只上报、**不改动产物**,也不在此处代替调用方做判决。
+
+ 为什么不在这里直接对着阈值抛错:交付 / 重试 / 让用户换母版是产品决策,阈值该由
+ server 按场景定;而且到这一步钱已经花完,引擎单方面丢弃产物只是把损失变成两份。
+ 引擎负责"如实报数",不负责"替上层决定这次算不算数"。
+
+ ``loop_seam`` 只对循环类动作量:一次性动作(jump/attack)首尾姿态本就不同,
+ 给它算一个"接缝"再交出去,等于发一个必然难看的数让上层照着做错误决定。
+ """
+ return ActionQuality(
+ motion_scale=motion_scale(frames),
+ dead_frames=dead_frame_indices(frames),
+ loop_seam=loop_seam(frames) if is_cyclic(action) else None,
+ # 分区动量:整幅指标的盲区补充。自动绑骨漏认一条肢体时那块网格每帧同姿势,
+ # 而 motion_scale 与死帧全部正常。
+ limbs=limb_motion(frames),
+ )
+
+ def _lastmile(
+ self,
+ frames: list[bytes],
+ progress: ProgressPort,
+ canvas: tuple[int, int] | None = None,
+ ) -> list[Image.Image]:
+ """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。
+
+ 返回 PIL 而不是 PNG bytes:紧接着的成色测量要按图看帧,再编码回 PNG 只为了
+ 让上一句话好听、下一句话又得解码回来。编码统一在 ``generate`` 出参那一步做。
+
+ 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段):
+ 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。
+
+ ``canvas`` 给定时直接对齐到该尺寸,而不是恒出 256 再让上层缩。上层那次缩放
+ (``Image.thumbnail`` 补边)**只缩不放**:项目要 512 时 256 的帧不会被放大,而是
+ 原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线 0.92 被挪到 0.709(2026-08-11
+ 实测),角色不站在地上、跨动作对齐也失效。在这里一次出到位就没有那一步了。
+ """
+ progress.step("lastmile", _TICK_LASTMILE, _TOTAL, "脚线对齐(原地)")
+ # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate),
+ # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。
+ if not frames:
+ raise ValueError("strategy 未产出任何帧")
+ bad = [i for i, f in enumerate(frames) if not f]
+ if bad:
+ raise ValueError(f"strategy 产出了 {len(bad)}/{len(frames)} 个空帧,索引 {bad[:8]}")
+ imgs = [_img(f) for f in frames]
+ # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏),
+ # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。
+ hs = []
+ for im in imgs:
+ ys, _ = np.where(np.asarray(im)[:, :, 3] > 128)
+ if len(ys):
+ hs.append(float(ys.max() - ys.min()))
+ # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧)
+ ref = float(np.median(hs)) if hs else None
+ if canvas is None:
+ return align_bottom_center(imgs, ref_height=ref)
+ cw, ch = canvas
+ return align_bottom_center(imgs, cell=cw, cell_h=ch, ref_height=ref)
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py
new file mode 100644
index 00000000..692c4eb7
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py
@@ -0,0 +1,46 @@
+"""用 LangChain Chat 模型从角色描述抽出短名称。"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from windup_framework.providers import create_chat_model
+
+NAME_MAX_LEN = 20
+
+_SYSTEM_PROMPT = (
+ "你从角色外观或人设描述中抽出一个适合资产库展示的称呼。"
+ "只输出名称本身,不要引号、标点或解释。"
+ f"名称不超过 {NAME_MAX_LEN} 个字,优先中文。"
+)
+
+
+def _clean_name(raw: str) -> str:
+ return raw.strip().strip("\"'“”‘’").strip()[:NAME_MAX_LEN]
+
+
+class LangChainCharacterNamer:
+ """``CharacterNamerPort`` 的 LangChain 实现。"""
+
+ def __init__(self, chat_model: Any | None = None) -> None:
+ # 装配期不创建 ChatOpenAI:CI / 本地无 AI_API_KEY 时 create_app 仍能起来。
+ self._model = chat_model
+
+ def _chat_model(self) -> Any:
+ if self._model is None:
+ self._model = create_chat_model()
+ return self._model
+
+ def name_from_description(self, description: str) -> str:
+ result = self._chat_model().invoke(
+ [
+ SystemMessage(content=_SYSTEM_PROMPT),
+ HumanMessage(content=description),
+ ]
+ )
+ content = getattr(result, "content", result)
+ if not isinstance(content, str):
+ content = str(content or "")
+ return _clean_name(content)
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py
new file mode 100644
index 00000000..7d6d8146
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py
@@ -0,0 +1,329 @@
+"""母版可生成性预检 —— 入口处**允许拒绝**的那道闸,在花钱之前。
+
+为什么有这个模块:ai_engine 此前所有 ``raise`` 都在输出侧,``generate(card, action,
+master, progress)`` 对 ``master`` 一个前置判定都没有。2026-08-07 实测:喂一张"人物在
+画板前作画"的图请求 walk,全程无一处报错,最终产出 16 帧构图完整的序列帧,画面是个
+不会走路的错角色 —— 钱已花完才发现。
+
+**本层判什么(全部本地零成本、可复现)**
+
+*拒绝(:class:`MasterRejected`,量到就是事实):*
+ ① 能否解码 —— 坏 bytes / 截断文件不必等 i2v 跑完再发现;
+ ② 有没有可动的主体 —— 全透明 / 全同色 = 画面里没有东西可动;
+ ③ 主体宽高比下游装不装得下 —— 见 :data:`REJECT_ASPECT`。
+
+*警告(:class:`MasterWarning`,近似判据,合法母版也会命中):*
+ ④ 下半身横切的连通段数 —— 见 :data:`LIMB_BANDS`;
+ ⑤ 主体之外还有没有独立色块 —— 见 :data:`MIN_EXTRA_COMPONENT_RATIO`。
+
+拒绝与警告的分界**由判据能不能证伪决定,不由后果严重程度决定**。④⑤ 指向的是混元图生 3D
+的硬约束(四肢粘连 → 绑骨绑不出腿;画面里有武器配件 → 明确不允许),后果比 ③ 更贵,
+但两条判据都会在合法母版上误报,所以只能警告。**上层拿它们做什么决定**:摆在母版确认闸
+上给人看,让人在付费建 3D 之前决定"就用这张 / 重新生成三张";任何一条都不阻断流程。
+
+**本层不判什么、为什么 —— 别把下面这些当成已经守住了:**
+ - **画面里有没有文字**(提示词含 "reference sheet" 时生图模型会自己糊上标注,烤进母版
+ 就带到每一帧)。**这条明确不做。** 纯 numpy 能做的文字信号(局部高频、笔画宽度一致、
+ 小连通块成行排列)在像素画角色身上恒亮:铆钉、扣子、牙齿、瞳孔高光全是"小而密的
+ 高对比块"。做出来的东西假阳性远多于真阳性,而假阳性会让人把一张好母版扔掉重生成 ——
+ 比漏报更贵。要真做,得上 OCR / 检测模型,那是一次按次计费的调用,与"零成本预检"
+ 不是一件事,得单独立项与实测。
+ - **和身体焊在一起的手持物**。⑤ 只逮得到与主体**不相连**的色块;剑握在手里、与手臂
+ 连成一片时它一个信号都没有 —— 与 ``providers.render3d.checks`` 那层"融进同一块网格的
+ 武器逮不到"是同一个盲区的上下两端。
+ - **画的是不是一个角色、是不是该动作要的姿态**(walk 要侧向、attack 要蓄力,见
+ :data:`master_prep.MASTER_POSES`)。需要视觉模型读画面语义,本层只有 numpy。
+ **开头那张"人物在画板前作画"的图,本预检拦不住**:它能解码、有主体、比例正常。
+ 本层挡的是它的近邻(空图 / 坏图 / 极端比例),挡不住"内容画错"。要真正堵住这个,
+ 得在预检里接一次廉价的视觉判定(便宜的 VLM 问一句"这是不是一个可行走的角色、
+ 朝向是不是侧面"),那是另一件事、要另外的实测与预算。
+ - **朝向与 ``ActionSpec.facing`` 是否一致** —— 同上,需要视觉模型。
+ - **背景干不干净到能抠图** —— 抠图是 ``MatteProvider``(rembg/u2net)的事;本层的
+ 四角中位色启发式判不出"这块背景 rembg 能不能抠掉"。
+ - **分辨率下限** —— 故意不判。i2v 供应商对首帧分辨率的真实下限我没有实测数据,
+ 拍一个阈值就是拿没验证的判据挡掉用户的钱。:data:`MIN_SUBJECT_SIDE` 只挡退化端
+ (小到与噪点无从区分),不是画质阈值。
+
+纯 PIL / numpy,零 API,不联网。
+"""
+from __future__ import annotations
+
+import io
+from dataclasses import dataclass, field
+
+import numpy as np
+from PIL import Image, UnidentifiedImageError
+
+from windup_ai_engine._subject import bbox_of, subject_mask
+from windup_ai_engine.ports import (
+ MasterRejectCode,
+ MasterRejected,
+ MasterWarning,
+ MasterWarningCode,
+)
+from windup_ai_engine.postprocess.pack import FILL_H, FILL_W
+
+__all__ = ["LIMB_BANDS", "MIN_EXTRA_COMPONENT_RATIO", "MIN_LIMB_RUN_PX",
+ "MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT",
+ "MasterFacts", "check_master", "component_sizes", "limb_segments",
+ "main_component", "reject_aspect_for"]
+
+# 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标
+# (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度
+# 退化成 cell*FILL_W/(w/h)。取"退化到目标高度的一半"为界:
+# FILL_W / R < FILL_H / 2 ⇒ R > 2*FILL_W/FILL_H ≈ 3.1
+# 再宽就不是"缩小了一点",是把角色压成一条。pack.py 记的实测(2026-08-05):w/h=1.78
+# 的狐狸母版丢 27px、w/h=2.0 只剩 79.9% 内容 —— 那还在兜底能救的区间内(交付变矮),
+# 3.1 以上则是"硬缩到没法看"。与其硬缩出一个能落库的错产物,不如在花钱前退回去。
+REJECT_ASPECT = 2 * FILL_W / FILL_H
+
+
+def reject_aspect_for(canvas: tuple[int, int] | None) -> float:
+ """给定交付画布下的实际比例上限。方形画布(或不指定)即 :data:`REJECT_ASPECT`。
+
+ 上面那条推导默认画布是方形 —— ``FILL_W`` 与 ``FILL_H`` 是同一条边长的两个比例。
+ 画布可以非方之后这个前提就不成立了:宽度兜底是 ``cw*FILL_W/主体宽``、高度目标是
+ ``ch*FILL_H/主体高``,同一条推导做下来是
+
+ R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch)
+
+ 即窄高画布(cw= 2`` 通过。
+LIMB_BANDS = (0.70, 0.80, 0.88, 0.94)
+
+# 一段至少这么宽才算数。描边、抗锯齿会在腿外侧留下 1–2px 的孤立像素,
+# 不滤掉的话一条腿会被数成三段,"分离"反而被误判成"更分离"。
+MIN_LIMB_RUN_PX = 3
+
+# 独立色块要达到最大块的这个比例才当成"另一个东西"。抗锯齿碎片、发梢、飞溅特效
+# 都会产生小连通块,阈值太低会让几乎每张母版都报警,而报警一多就没人看了。
+MIN_EXTRA_COMPONENT_RATIO = 0.02
+
+
+def _runs(row: np.ndarray, min_px: int = 1) -> list[tuple[int, int]]:
+ """一行里的连通段 ``[(x0, x1), ...]``(半开),短于 ``min_px`` 的丢掉。"""
+ edges = np.flatnonzero(np.diff(np.concatenate(([0], row.astype(np.int8), [0]))))
+ return [(int(a), int(b)) for a, b in zip(edges[::2], edges[1::2], strict=True)
+ if b - a >= min_px]
+
+
+def limb_segments(mask: np.ndarray, box: tuple[int, int, int, int]) -> tuple[int, ...]:
+ """:data:`LIMB_BANDS` 各处的横向连通段数,与 ``LIMB_BANDS`` 一一对应。"""
+ x0, y0, x1, y1 = box
+ span = y1 - y0 - 1
+ return tuple(
+ len(_runs(mask[min(y1 - 1, y0 + int(round(frac * span))), x0:x1], MIN_LIMB_RUN_PX))
+ for frac in LIMB_BANDS
+ )
+
+
+def _label(mask: np.ndarray) -> tuple[list[tuple[int, int, int, int]], dict[int, int]]:
+ """连通块标注:``[(y, x0, x1, root), ...]`` + 各 root 的像素总数。**八邻接**。
+
+ 按行的连通段做并查集而不是逐像素扫:段数比像素数小三四个数量级,一张 1024² 的母版
+ 只有几千段。八邻接是刻意的 —— 四邻接会把抗锯齿造成的对角细颈判成断开,于是同一条
+ 手臂被数成两块,凭空多出一个"独立色块"警告。
+ """
+ parent: list[int] = []
+
+ def find(i: int) -> int:
+ root = i
+ while parent[root] != root:
+ root = parent[root]
+ while parent[i] != root:
+ parent[i], i = root, parent[i]
+ return root
+
+ labelled: list[tuple[int, int, int, int]] = []
+ previous: list[tuple[int, int, int]] = []
+ for y in range(mask.shape[0]):
+ current: list[tuple[int, int, int]] = []
+ for a, b in _runs(mask[y]):
+ label = len(parent)
+ parent.append(label)
+ for pa, pb, plabel in previous:
+ if a <= pb and pa <= b: # 端点相碰即视为连通 = 八邻接
+ ra, rb = find(label), find(plabel)
+ if ra != rb:
+ parent[rb] = ra
+ labelled.append((y, a, b, label))
+ current.append((a, b, label))
+ previous = current
+
+ resolved = [(y, a, b, find(label)) for y, a, b, label in labelled]
+ totals: dict[int, int] = {}
+ for _, a, b, root in resolved:
+ totals[root] = totals.get(root, 0) + (b - a)
+ return resolved, totals
+
+
+def component_sizes(mask: np.ndarray) -> tuple[int, ...]:
+ """各连通块的像素数,从大到小。"""
+ return tuple(sorted(_label(mask)[1].values(), reverse=True))
+
+
+def main_component(mask: np.ndarray) -> np.ndarray:
+ """只保留最大连通块的掩码 —— 数腿之前必须先把画面里的别的东西剔掉。
+
+ 不剔的后果是两条警告互相架空:一把浮在腿侧的剑会在腿所在的那几行多贡献一段,
+ 于是"两腿粘连"被凑够 2 段、警告消失 —— 母版越糟糕反而越安静。
+ """
+ runs, totals = _label(mask)
+ if not totals:
+ return mask
+ root = max(totals.items(), key=lambda kv: (kv[1], -kv[0]))[0]
+ out = np.zeros_like(mask)
+ for y, a, b, r in runs:
+ if r == root:
+ out[y, a:b] = True
+ return out
+
+
+@dataclass(frozen=True)
+class MasterFacts:
+ """预检**量到**的母版形态。返回它而不是只返 None:通过时这些数进进度文案,
+ 出问题时(比如误拒)一眼看得出引擎当时把什么当成了主体。"""
+
+ size: tuple[int, int] # 母版画布 (w, h)
+ subject_box: tuple[int, int, int, int] # 主体包围盒 (x0, y0, x1, y1),半开
+ subject_ratio: float # 主体 w/h
+ subject_area_ratio: float # 主体像素 / 画幅像素
+ limb_segments: tuple[int, ...] = () # LIMB_BANDS 各处的横向连通段数
+ components: tuple[int, ...] = () # 够大的连通块像素数,从大到小
+ warnings: tuple[MasterWarning, ...] = field(default_factory=tuple)
+
+ def note(self) -> str:
+ """给 ProgressPort 的一行摘要(会经 server 变成用户看到的进度文案)。"""
+ w, h = self.size
+ x0, y0, x1, y1 = self.subject_box
+ tail = f";{len(self.warnings)} 条警告" if self.warnings else ""
+ return (f"母版 {w}×{h},主体 {x1 - x0}×{y1 - y0}"
+ f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})"
+ f"{tail}")
+
+
+def _decode(master: bytes) -> Image.Image:
+ """解码母版;坏 bytes 直接拒。
+
+ 必须 ``load()`` 强制解完:``Image.open`` 只读文件头,截断的 PNG 在 open 处不报错,
+ 要到下游某个 ``convert`` / ``np.asarray`` 才炸 —— 那时 i2v 的钱已经花了。
+ """
+ if not master:
+ raise MasterRejected(MasterRejectCode.UNDECODABLE, "母版为空 bytes")
+ try:
+ img = Image.open(io.BytesIO(master))
+ img.load()
+ return img.convert("RGBA")
+ except (UnidentifiedImageError, OSError, ValueError) as exc:
+ raise MasterRejected(
+ MasterRejectCode.UNDECODABLE, f"解不开这张图({type(exc).__name__}: {exc})"
+ ) from exc
+
+
+def _warnings(
+ segments: tuple[int, ...], blocks: tuple[int, ...]
+) -> tuple[MasterWarning, ...]:
+ """把量到的两组数翻成警告。**只在信号明确时出声** —— 详见各条的假阳性来源。"""
+ out: list[MasterWarning] = []
+ if segments and max(segments) < 2:
+ out.append(MasterWarning(
+ MasterWarningCode.LIMBS_FUSED,
+ f"下半身 {list(LIMB_BANDS)} 四处横切都只有 {list(segments)} 段主体像素,"
+ "两腿之间量不到空隙。混元靠这道空隙分左右腿,粘连时会绑出一条腿的骨架,"
+ "而接口不会报错。**侧视角色两腿前后重叠时本条必然误报**,"
+ "确认这张是侧视就忽略它。",
+ ))
+ if len(blocks) > 1:
+ extra = ", ".join(f"{n}px" for n in blocks[1:])
+ out.append(MasterWarning(
+ MasterWarningCode.EXTRA_COMPONENT,
+ f"主体({blocks[0]}px)之外还有 {len(blocks) - 1} 块独立色块({extra})。"
+ "混元明写送检模型不得含人体以外的组件,画面里的武器/道具会被一起建进网格、"
+ "再被绑上权重乱甩。也可能是生图模型自己糊上的标注文字。"
+ "**与身体相连的手持物本条逮不到**,只能靠人看。",
+ ))
+ return tuple(out)
+
+
+def check_master(master: bytes, canvas: tuple[int, int] | None = None) -> MasterFacts:
+ """母版可生成性预检。通过返回量到的形态(含警告),不通过抛 :class:`MasterRejected`。
+
+ 只看母版本身,不看 ``ActionSpec``:拒绝那三条都是"下游画布装不装得下 / 有没有东西可
+ 动",与动作类型无关。动作相关的母版要求(侧向 / 蓄力姿态)本层判不了,见模块 docstring。
+
+ 警告**不影响返回**:调用方拿到 facts 就是通过了,``facts.warnings`` 要不要理是它的事。
+ 这样定是因为两条警告判据都会在合法母版上误报,让它们阻断流程等于把误报变成挡路。
+
+ ``canvas``:交付画布 ``(宽, 高)``。只影响比例上限 —— 见 :func:`reject_aspect_for`。
+ 不给即按方形判(与加这个入参之前完全一致)。**必须与出帧用的是同一个 canvas**,
+ 否则就成了"预检按一套几何判、出帧按另一套出"。
+ """
+ img = _decode(master)
+ w, h = img.size
+ mask = subject_mask(img)
+ found = bbox_of(mask)
+ if found is None:
+ raise MasterRejected(
+ MasterRejectCode.NO_SUBJECT,
+ f"{w}×{h} 的图里找不到主体(全透明或全同色),没有可动的东西",
+ )
+ box, pixels = found
+ bw, bh = box[2] - box[0], box[3] - box[1]
+ body = main_component(mask)
+ body_box = bbox_of(body)
+ segments = limb_segments(body, body_box[0]) if body_box else ()
+ blocks = component_sizes(mask)
+ kept = tuple(n for n in blocks if n >= blocks[0] * MIN_EXTRA_COMPONENT_RATIO)
+ facts = MasterFacts(
+ size=(w, h),
+ subject_box=box,
+ subject_ratio=bw / bh,
+ subject_area_ratio=pixels / max(1, w * h),
+ limb_segments=segments,
+ components=kept,
+ warnings=_warnings(segments, kept),
+ )
+ if min(bw, bh) < MIN_SUBJECT_SIDE:
+ raise MasterRejected(
+ MasterRejectCode.SUBJECT_TOO_SMALL,
+ f"主体包围盒只有 {bw}×{bh}px(下限 {MIN_SUBJECT_SIDE}px),"
+ "与一粒噪点/水印无从区分",
+ )
+ if facts.subject_area_ratio < MIN_SUBJECT_AREA_RATIO:
+ raise MasterRejected(
+ MasterRejectCode.SUBJECT_TOO_SMALL,
+ f"主体只占画幅 {facts.subject_area_ratio:.4%}"
+ f"(下限 {MIN_SUBJECT_AREA_RATIO:.1%}),像散落的噪点而不是角色",
+ )
+ limit = reject_aspect_for(canvas)
+ if facts.subject_ratio > limit:
+ raise MasterRejected(
+ MasterRejectCode.ASPECT_TOO_WIDE,
+ f"主体 w/h={facts.subject_ratio:.2f} 超过 {limit:.2f};"
+ "下游画布装不下,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版",
+ )
+ return facts
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py
new file mode 100644
index 00000000..68d28959
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py
@@ -0,0 +1,79 @@
+"""母版规格与预处理:每个动作需要什么样的母版。
+
+**核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。**
+ - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。
+ - jump:母版**顶部留白**才不被视频画面裁掉。
+ - attack:必须给**极限蓄力母版**(发力那一侧已拉到待发位)。用站立母版时,即使提示词
+ 写死"不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作
+ 先验压不住;换蓄力母版后模型只能"接着往前发力",没有再抡起的空间。
+ 蓄力姿态按运动拓扑分四支(见 :data:`ATTACK_MASTER_POSES`):同一张横挥蓄力母版
+ 喂给直刺 / 远程 / 前扑,模型会先把收好的那一侧重新抡起来再做。
+
+**姿势描述里不写装备名词(#195)。** 这几段是拿去生成母版的提示词,写"the weapon"等于
+断言角色持械 —— 空手角色会被凭空塞一把武器,而母版是整条 i2v 链的身份来源,污染会一路
+带到所有动作。改为"出手的那只手 / 手里若有东西"这类存在无关的写法,几何约束(拉到腰际、
+不过肩)一条不少。同 :mod:`.prompt.walk`。
+
+
+实测教训:母版里角色居中、占 ~70% 画面高时,i2v 跳跃会让角色**头顶顶出视频画面上沿**
+被裁掉(生成本身没错,是构图没留够空间)。规则同 MasterSpec 的"运动方向多留白":
+ - jump:向上运动 → 顶部补空间,角色坐低
+ - dash / walk / run:向右位移 → 前进方向多留白(由母版生成时构图保证,此处不改)
+
+纯 PIL,零 API。背景色取母版四角中位色,补出来的边与母版底色一致。
+"""
+
+from __future__ import annotations
+
+import io
+
+from PIL import Image
+
+from windup_common.models import AttackArchetype
+
+from windup_ai_engine._subject import bg_color as _bg_color
+from windup_ai_engine.prompt._md import load_section
+
+__all__ = ["add_headroom", "prepare_master", "MASTER_POSES", "ATTACK_MASTER_POSES"]
+
+# 空值 = 该动作用中性站立母版即可。这是唯一允许空提示词的地方,故显式放行 ——
+# 别处的空串会一路跑到付费调用。
+MASTER_POSES = {
+ a: load_section("master_poses.md", a, allow_empty=True)
+ for a in ("walk", "run", "idle", "jump")
+}
+
+# attack 按运动拓扑取母版姿态:四支的起手姿态互不兼容(横挥蓄力母版跑不出直刺),
+# 而"母版姿态决定动作"对 attack 最狠 —— 见本模块开头。这里不放行空值:
+# 四支都必须有自己的蓄力姿态,缺一支就该炸,不能退回中性站立。
+ATTACK_MASTER_POSES = {
+ arch: load_section("master_poses.md", f"attack.{arch.value}")
+ for arch in AttackArchetype
+}
+
+
+def add_headroom(master: bytes, ratio: float = 0.6) -> bytes:
+ """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。
+
+ Args:
+ master: 母版图 bytes。
+ ratio: 处理后角色所占的画面高度比例(越小头顶空间越多)。0.6 表示角色高度
+ 约占新画面的 60%,上方留约 40%。
+ """
+ if not 0.1 < ratio < 1.0:
+ raise ValueError("ratio 需在 (0.1, 1.0) 之间")
+ img = Image.open(io.BytesIO(master)).convert("RGB")
+ new_h = max(img.height + 1, int(round(img.height / ratio)))
+ canvas = Image.new("RGB", (img.width, new_h), _bg_color(img))
+ canvas.paste(img, (0, new_h - img.height)) # 原图贴底,空间加在顶部
+ buf = io.BytesIO()
+ canvas.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def prepare_master(master: bytes, action: str) -> bytes:
+ """按动作类型预处理母版;不需要处理的动作原样返回。"""
+ if action in ("jump", "attack"):
+ # jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶)
+ return add_headroom(master, ratio=0.62 if action == "jump" else 0.70)
+ return master
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/ports/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py
new file mode 100644
index 00000000..147af5ae
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py
@@ -0,0 +1,227 @@
+"""ai_engine 对外契约(ports)—— server 只 import 这里,不碰 slicing / strategy / impl。
+
+CI 的 import-linter 分层门禁会强制:app.server 依赖只到 ai_engine.ports。
+换掉内部实现(strategy / provider)时 server 零改动。
+
+MVP 边界(与作者对齐):ai_engine **只产出帧 bytes + 进度**,不碰存储 / DB。
+母版(master)由 server 侧从 ``Character.reference_image_url`` 取好、以 bytes 传入;
+产出的帧由 server 侧上传对象存储、落 ``character_data``。故本层无 ArtifactStore 依赖。
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Protocol, runtime_checkable
+
+from windup_common.models import ActionSpec, CharacterCard
+
+
+# ---- server 实现、注入给 ai_engine 的进度回调 port ----
+class ProgressPort(Protocol):
+ """进度上报 —— server 转 SSE / 轮询状态(取代管线里的 print)。"""
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None: ...
+
+
+# ---- 入口拒绝(在花钱之前)----
+class MasterRejectCode(str, Enum):
+ """母版被拒的原因 —— server 据此选文案,别用异常消息做分支(消息会改)。
+
+ 取值全部是**本地零成本可判**的形态问题;判不了的(画的是不是角色、朝向对不对)
+ 不在此列,见 :mod:`windup_ai_engine.master_check` 的"本层不判什么"。
+ """
+
+ UNDECODABLE = "undecodable" # 不是图 / 截断 / 编码不支持
+ NO_SUBJECT = "no_subject" # 全透明或全同色:没有可动的东西
+ SUBJECT_TOO_SMALL = "subject_too_small" # 主体小到与噪点/水印无从区分
+ ASPECT_TOO_WIDE = "aspect_too_wide" # 主体太扁,方形 cell 里只能压成一条
+
+
+class MasterWarningCode(str, Enum):
+ """母版**可疑但不足以拒**的形态 —— 判据本身是近似的,误拒的代价高于漏放。
+
+ 与 :class:`MasterRejectCode` 的分工由**判据能不能证伪**决定,不由后果严重程度决定:
+ 拒绝码那几条量到就是事实(解不开、没主体、比例超限);警告码这几条量到的是相关信号,
+ 合法母版也可能命中(见 :mod:`windup_ai_engine.master_check` 各条的假阳性来源)。
+ """
+
+ LIMBS_FUSED = "limbs_fused" # 下半身横切只有一段:双腿可能粘连
+ EXTRA_COMPONENT = "extra_component" # 主体之外还有独立色块:可能是手持物/道具/标注
+
+
+@dataclass(frozen=True)
+class MasterWarning:
+ """一条警告。``code`` 给上层做分支,``detail`` 给人看。"""
+
+ code: MasterWarningCode
+ detail: str
+
+
+class MasterRejected(ValueError):
+ """母版不具备可生成性,在**调用付费模型之前**拒绝。
+
+ 与 ai_engine 其他异常的分工(这条分工是给 server 用的):
+ - ``MasterRejected`` = **调用方的输入不行**,同一张母版重试多少次都一样。
+ server 应映射成 4xx、把 ``code`` 翻成"请换一张母版"类文案,**不要重试**。
+ - ``NotImplementedError`` / 其他 ``ValueError`` = 引擎侧装配或产出出了问题
+ (路线没注入、strategy 吐空帧、帧数对不上),属于 5xx、要人介入,
+ 让用户换母版是把锅甩错地方。
+ """
+
+ def __init__(self, code: MasterRejectCode, detail: str) -> None:
+ super().__init__(f"母版不可用({code.value}):{detail}")
+ self.code = code
+ self.detail = detail
+
+
+# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)----
+@dataclass(frozen=True)
+class ActionQuality:
+ """这一次出帧的成色 —— 让上层能判"交付 / 重试 / 让用户换母版"。
+
+ 没有这个,``GeneratedAction`` 只能表达"生成完了",不能表达"生成得怎么样":
+ 一段**每帧都一样**的 walk 和一段步态干净的 walk,帧数、时长、fps 完全相同,
+ 调用方分辨不出 —— 本仓吃过四次的正是这类"看起来成功的错结果"。
+
+ 四个字段各自不可由其余推导(下面逐条说明必要性)。刻意**没有**的字段:
+ - 糊帧率(``slicing.quality.blur_ratio``):2026-08-05 实测 6 段真 i2v
+ **没有一帧糊帧**,加进来是个恒等于 1 的常数,上层拿它做不了任何决定。
+ 真出现糊帧再加,那时才有阈值可依。
+ - 抽帧降级原因(``slicing.pick_cycle`` 的三条退化路径):见该函数 docstring 里
+ 记的缺口。降级**对交付物的后果**由 ``loop_seam`` 直接测得,而"降级的原因"
+ 今天没有任何调用方会据此改变行为,故不塞进出参。
+ """
+
+ limbs: dict[str, float] = field(default_factory=dict, kw_only=True)
+ """分区动量(``slicing.quality.limb_motion``):``{区名: 占比}`` + 最静区名 ``still``。
+
+ 补 ``motion_scale`` / ``dead_frames`` 的共同盲区 —— 那两个看的是**整幅**,而
+ "腿在迈、手臂僵成柱子"整幅指标完全正常。某个该动的区占比接近 0 = 那块网格没被
+ 骨骼驱动(多半是自动绑骨漏认了肢体),别交付。
+ """
+
+ motion_scale: float
+ """交付帧的相邻帧平均差异(48×48 灰度绝对尺度)。**0.0 = N 张同一张图。**
+
+ 上层拿它做的决定:接近 0 → 这不是动画,**不要交付**(退款 / 重试 / 提示母版
+ 姿态不适合该动作)。它与 ``dead_frames`` 不重复而是互补 —— ``dead_frames``
+ 的两条判据都是相对的(比邻居、比自身 p75),整段完全冻结时全部不成立、
+ 一帧死帧都报不出(见 ``slicing.quality.motion_scale`` 的实测说明)。
+ """
+
+ dead_frames: tuple[int, ...]
+ """与前一帧几乎无变化的帧下标(下标 0 不参与判定:它没有前一帧)。
+
+ 上层拿它做的决定:``len(dead_frames)/len(frames)`` 偏高 → 用户花 N 帧的钱只拿到
+ N-K 个不同姿态,提示重试或换母版。给**下标**而不是个数,是因为分布形态对应两种
+ 不同的病、修法不同:连续一段 = 动作停住(母版姿态不对 / 视频后半段衰减),
+ 隔帧散布 = 有效帧率减半(i2v 复制帧),前者换母版、后者调抽帧密度。
+ """
+
+ loop_seam: float | None
+ """末帧接回首帧的跳幅 ÷ 相邻帧平均步长。1.0 ≈ 接缝与一个正常帧间步长同量级。
+
+ 上层拿它做的决定:循环类动作(idle/walk/run)会被引擎反复播放,接缝大就是肉眼
+ 可见的"跳一下";超过约 1.2 → 提示重试。取归一化值而不是原始差,是为了让不同
+ 动作幅度之间可比。
+
+ ``None`` = **这个数在本次生成里不可读**,两种情形:一次性动作(jump/attack/hit)
+ 本就不闭环;或 ``motion_scale`` 为 0(整段静止,连"一个正常步长"都没有,归一化
+ 无从谈起)。调用方要区分就看 ``motion_scale``,**不要把 None 当 0.0** ——
+ 0.0 会被读成"完美闭环",正是本仓忌讳的"貌似合理的默认值"。
+ """
+
+
+@dataclass
+class GeneratedAction:
+ """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长 + 成色。
+
+ frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成
+ ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。
+ """
+
+ frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序
+ # 播放时序的**唯一**真相源。曾另有一个 fps 字段抄自入参,与本字段互相矛盾:
+ # fps=20 宣称 50ms/帧,而 walk 这里给的是 125ms/帧 —— 同一段素材两个播放速度,
+ # 取哪个看消费方心情(2026-08-10 机器审 P2)。逐帧 ms 严格更能表达(关键帧定格),
+ # 所以删 fps 保 durations;真要单一帧率,由消费方从本字段算。
+ durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长
+ # 无默认值、且 kw_only 让它能排在有默认值的字段之后:**不给"没测"留缺省**。
+ # 给个 None 缺省的话,漏测与"测出来没问题"在调用方看来一模一样,而这个出参的
+ # 全部意义就是把这两者分开。
+ quality: ActionQuality = field(kw_only=True)
+
+
+# ---- ai_engine 暴露给 server(server 调用的唯一入口)----
+@runtime_checkable
+class CharacterGeneratorPort(Protocol):
+ """生成入口:角色卡 + 动作规格 + 母版 → 帧序列产物。
+
+ 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。
+
+ Args:
+ card: 角色卡。**当前唯一实现的视频路线一个字段都不读**——``git grep 'card\\.'``
+ 在 ai_engine 下零命中(2026-08-08 复核)。这不是遗漏:i2v 的角色身份完全由
+ ``master`` 这张母版图像承载,身份描述再写一遍反而会和母版打架。本参数是给
+ 未实现路线预留的入参:逐帧图生图(#53)要靠 ``name`` / ``desc`` 在每帧提示词里
+ 锁一致性。**调用方不要指望改 card 能影响视频路线的产出。**
+ action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。视频路线的实际入参在这里:
+ ``action``、``n_frames``、``facing``、``stylize`` 等。
+ master: 定妆母版图 bytes(server 从 reference_image_url 取)。**视频路线的
+ 角色一致性靠它,不靠 card。** 进付费模型之前会先过一遍可生成性预检,
+ 见 Raises。
+ progress: 进度回调。
+ canvas: 交付画布 ``(宽, 高)``,单位像素。``None`` = 引擎默认(256 方形)。
+ **给上层传项目 sprite 尺寸用的。** 不给的话引擎恒出 256,上层要缩到项目
+ 尺寸就得再来一次重采样;而那一步用 ``Image.thumbnail``(只缩不放),放大
+ 方向根本不放大、还会把脚线从 0.92 挪到 0.709(2026-08-11 实测),角色不
+ 站在地上。让引擎一次出到目标尺寸,那次二次缩放就整个消掉。
+ 画布几何按比例定义,故任何尺寸下构图不变、母版预检阈值同样有效。
+
+ Raises:
+ MasterRejected: 母版形态不可生成(见 :class:`MasterRejectCode`)。**在花钱
+ 之前抛**,同一张母版重试无意义 → server 映射 4xx、请用户换母版。
+ NotImplementedError: 该动作分流到的路线没有实现或没注入 strategy。
+ ValueError: 产出对不上契约(空帧 / 帧数不足)。钱已经花了,但错产物不放行。
+
+ 出参的 ``GeneratedAction.quality`` 是**必填**的成色读数:帧数对、无异常并不
+ 等于产物可用,调用方交付前应据它决定交付 / 重试 / 让用户换母版。
+ """
+
+ def generate(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ canvas: tuple[int, int] | None = None,
+ ) -> GeneratedAction: ...
+
+ def generate_rendered(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ rigged_model: bytes,
+ progress: ProgressPort,
+ canvas: tuple[int, int] | None = None,
+ ) -> GeneratedAction:
+ """三渲二:拿**已绑骨的 3D 模型**套预设动作、渲成 2D 序列帧。
+
+ 与 :meth:`generate` 并列而不另立 port —— server 调 ai_engine 只该有一个入口。
+ **调哪个由 server 决定,引擎不选**:判据"该造型有没有 3D 资产"只有 DB 知道
+ (``character_data.outfits[].model_3d_url``),故本方法不提供"能不能用"的预查询。
+ ``rigged_model`` 传 bytes 不传 URL —— ai_engine 只吃 bytes、不碰存储;取模型与
+ **建**模型那笔按次计费都在 server 侧,不在这条出帧路径上。
+
+ Raises:
+ NotImplementedError: 没注入 ``GenRoute.RENDER_3D`` 的 strategy。
+ ValueError: 模型渲不出请求朝向 / 产出帧数对不上契约。
+ """
+ ...
+
+
+@runtime_checkable
+class CharacterNamerPort(Protocol):
+ """根据角色描述生成短名称。不是 Agent,只是一次 LLM 调用。"""
+
+ def name_from_description(self, description: str) -> str: ...
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py
new file mode 100644
index 00000000..e69f0ec7
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py
@@ -0,0 +1,28 @@
+"""后处理:把选好的帧落地成交付级序列帧(像素化 / 对齐 / 打包)。
+
+抽帧 / 选帧见 :mod:`..slicing`。逐帧时长 ``frame_durations`` 在 :mod:`.rootmotion`。
+"""
+
+from .rootmotion import DEFAULT_FPS_MS, extract_root_motion, frame_durations
+from .pixelate import (
+ detect_pixel_size,
+ extract_palette,
+ master_pixel_spec,
+ pixelate_frames,
+ to_pixel_art,
+)
+from .pack import align_bottom_center, save_gif, sprite_sheet
+
+__all__ = [
+ "to_pixel_art",
+ "pixelate_frames",
+ "detect_pixel_size",
+ "extract_palette",
+ "master_pixel_spec",
+ "extract_root_motion",
+ "frame_durations",
+ "DEFAULT_FPS_MS",
+ "align_bottom_center",
+ "sprite_sheet",
+ "save_gif",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py
new file mode 100644
index 00000000..2fc0321a
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py
@@ -0,0 +1,303 @@
+"""对齐 / 打包(后处理的收尾:脚线对齐 → sprite sheet / gif)。
+
+抽帧 / 选帧见 :mod:`..slicing`,像素化见 :mod:`.pixelate`,抠图见 framework 的
+MatteProvider(#20)。本模块把对齐后的帧拼成交付物。
+"""
+
+from __future__ import annotations
+
+import logging
+
+import numpy as np
+from PIL import Image
+
+_logger = logging.getLogger(__name__)
+
+__all__ = ["CELL", "CORE_THICKNESS", "FILL_H", "FILL_W", "FOOT_LINE",
+ "align_bottom_center", "core_span", "sprite_sheet", "save_gif"]
+
+# 交付画布的几何 —— 提成模块常量而不是只当默认参数,是因为**入口预检要按同一套几何
+# 判母版能不能装下**(见 master_check.REJECT_ASPECT)。抄一份数字过去就等于埋下
+# "改了这里、那边阈值不动"的静默分歧。
+CELL = 256 # 方形 cell 边长(交付序列帧的画布)
+FOOT_LINE = 0.92 # 脚线在画布中的高度比例
+FILL_H = 0.62 # 参考姿态占画布高的比例(留余量给举过头顶的动作)
+FILL_W = 0.96 # 主体占画布宽的上限(宽度兜底的天花板)
+
+# "厚"的门槛:某行/列的主体像素数达到该帧行/列宽度**中位数**的这个比例,才算本体。
+# 0.25 之下是延展物(尾巴、翅膀、披风、举起的武器)—— 它们细,本体厚。
+CORE_THICKNESS = 0.25
+
+
+def core_span(frame: Image.Image, thickness: float = CORE_THICKNESS) -> tuple[float, float] | None:
+ """本体的 (高, 宽),单位=该帧像素。空帧返回 ``None``。
+
+ **不能拿整体包围盒当"角色多大"** —— 包围盒被任何延展物撑大,而延展物的幅度随动作变,
+ 于是同一个角色在不同动作里定标出不同尺寸。实测偏差最大到 45%(龙张翼 55.3%、
+ 鸟展翅 57.0%、人形举武器 68.4%)。
+
+ 判据只认厚薄、不认语义:尾巴、翅膀、武器、披风、触手、长发,只要比本体薄就自动排除。
+ 所以它不带任何体形先验,四足 / 鸟 / 龙 / 人形共用一套。
+ """
+ import numpy as np
+
+ m = np.asarray(frame)[:, :, 3] > 128
+ rows, cols = m.sum(1), m.sum(0)
+ if not rows.any():
+ return None
+
+ # 行与列的门槛基准不同,因为延展物对两者的污染方向是**相反的**。以一条横展的翅膀为例:
+ # · 它是全图最宽的那**一行** → 行方向若以 max 为基准,门槛被抬到身体之上,身体每行
+ # 都判成"细的",量出的本体高只剩 9px(真值 69)。故行用**中位数**:它反映"大部分行
+ # 有多宽",不被少数极端行带偏。
+ # · 它又让**大量列**只有 10px 高 → 列方向若以中位数为基准,中位数被压到 10、门槛低到
+ # 2,翅膀整条算进本体,量出的本体宽 209px(真值 49)。故列用 **max**。
+ # 判据不对称是数据形态决定的,不是漏了统一。
+ def span(counts: np.ndarray, base: float) -> float:
+ keep = np.flatnonzero(counts >= base * thickness)
+ return float(keep.max() - keep.min())
+
+ nz_rows = rows[rows > 0]
+ return (span(rows, float(np.median(nz_rows))), span(cols, float(cols.max())))
+
+
+# 单调漂移的判定门槛(整段首尾相对变化)。低于它的不动 —— 真实身高起伏实测约 4%,
+# 把那也当漂移消掉,就成了原设计担心的"蹲下的帧被放大"。
+DRIFT_MIN_RATIO = 0.08
+
+# 判定"这是推镜、不是姿态变化"的容差:推镜把整个角色等比放大,高与宽的首尾相对变化应当
+# 同号且同量级;真实姿态(深蹲→起跳)只改高、宽度基本不动。取 0.5 = 宽的变化至少要达到
+# 高的一半才认推镜 —— 门槛过严会漏掉带轻微形变的真推镜,过松会把 jump 判成漂移。
+DRIFT_WIDTH_AGREEMENT = 0.5
+
+
+def _trend_ratio(values: list[float | None]) -> float | None:
+ """逐帧序列的线性趋势首尾相对变化;观测不足或拟合出非正值返回 ``None``。"""
+ x = np.array([i for i, v in enumerate(values) if v is not None], dtype=float)
+ a = np.array([v for v in values if v is not None], dtype=float)
+ if len(a) < 4:
+ return None
+ k, b = np.polyfit(x, a, 1)
+ trend = k * x + b
+ if trend.min() <= 0:
+ return None
+ return float(trend[-1] / trend[0] - 1.0)
+
+
+def _looks_like_camera_zoom(
+ spans: list[float | None], widths: list[float | None], height_ratio: float
+) -> bool:
+ """高宽是否一起变 —— 区分推镜与真实姿态变化。
+
+ 推镜等比放大整个角色,高与宽的趋势同号且同量级;深蹲→起跳只把高拉长,宽基本不动。
+ 量不出宽度趋势时返回 True,退回旧行为:宁可补偿一次可疑的,也不因为量不到就整段不补。
+ """
+ width_ratio = _trend_ratio(widths)
+ if width_ratio is None:
+ return True
+ if width_ratio * height_ratio <= 0: # 反号:一个变大一个变小,不是推镜
+ return False
+ return abs(width_ratio) >= abs(height_ratio) * DRIFT_WIDTH_AGREEMENT
+
+
+def scale_drift(
+ spans: list[float | None], widths: list[float | None] | None = None
+) -> tuple[list[float], float]:
+ """把逐帧本体高里的**单调趋势**分离出来,返回(逐帧补偿系数, 首尾相对变化)。
+
+ 存在的理由是整段共用一个缩放系数会原样保留 i2v 的推镜:实测线上两段真实产出,
+ 本体高从 137→165(+20%)与 70→158(+127%),几乎无回落。统一缩放对整段乘同一个数,
+ 趋势不受影响,于是角色在一个动作内单调变大。
+
+ 只除趋势、不逐帧归一:后者会把走路自然的身高起伏(约 4%)一起压平,蹲下的帧被放大、
+ 伸展的帧被缩小 —— 那正是本模块最初拒绝逐帧归一的原因。对本体高做一次线性拟合,
+ 补偿拟合值、保留残差,两个目标就不再冲突(实测修后趋势归零,残差 1.5%–6.7%)。
+
+ ``None`` = 空帧(量不到本体):不参与拟合、系数取 1.0,其余帧照常补偿。
+
+ 返回的系数以 1.0 为中心(除以均值),所以整段的**平均**尺寸不变,跨动作口径不受影响。
+ """
+ n = len(spans)
+ # 自变量用**真实帧号**而不是压缩后的序号:主要是系数必须落回对应的帧,否则空洞之后
+ # 整体错位一帧;顺带也不让空洞压短趋势的时间轴(32 帧缺 1 实测斜率差 3.7%,
+ # 落到逐帧系数上 <0.3%)。
+ x = np.array([i for i, s in enumerate(spans) if s is not None], dtype=float)
+ a = np.array([s for s in spans if s is not None], dtype=float)
+ if len(a) < 4: # 观测不足:三点拟不出可信趋势,拟合反而制造漂移
+ return [1.0] * n, 0.0
+ k, b = np.polyfit(x, a, 1)
+ trend = k * x + b
+ if trend.min() <= 0: # 拟合出非正值:数据不适合线性描述,不动
+ return [1.0] * n, 0.0
+ ratio = float(trend[-1] / trend[0] - 1.0)
+ if abs(ratio) < DRIFT_MIN_RATIO:
+ return [1.0] * n, ratio
+ if widths is not None and not _looks_like_camera_zoom(spans, widths, ratio):
+ # 高在变而宽没跟着变 = 真实姿态(深蹲→起跳),不是推镜。补偿它会把高度拉平、
+ # 同时把宽度按同一系数缩掉,姿态被压扁。
+ return [1.0] * n, ratio
+ comp = [1.0] * n
+ for i, c in zip(x.astype(int), trend / trend.mean(), strict=True):
+ comp[i] = float(c)
+ return comp, ratio
+
+
+def align_bottom_center(
+ frames: list[Image.Image],
+ cell: int = CELL,
+ foot_line: float = FOOT_LINE,
+ fill_h: float = FILL_H,
+ fill_w: float = FILL_W,
+ preserve_lift: bool = False,
+ ref_height: float | None = None,
+ cell_h: int | None = None,
+) -> list[Image.Image]:
+ """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。
+
+ **整段共用一个缩放系数**(取全序列最高帧定标),不逐帧归一化 —— 逐帧各自缩放到等高
+ 会把走路自然的身高起伏(实测约 4%)反向变成"忽大忽小":蹲下的帧被放大、伸展的帧被
+ 缩小。统一缩放后帧间只剩真实姿态差,尺度稳定。
+
+ 水平方向按**主体水平中心**对齐(不含挥出的武器会更好,当前用整体包围盒中心兜底);
+ 垂直方向按**脚线**(包围盒底边)对齐到 ``foot_line``。
+
+ ``ref_height``:**跨动作一致性的关键**,单位=传入帧的像素高。给定时按它定标,否则按本
+ 序列最高帧。按最高帧定标会让"举过头顶"的动作整段被缩小去迁就那一帧 —— 实测攻击时
+ 斧头高举使 bbox 从 485 涨到 660,角色本体因此明显变小;跳跃顶点同理。故传入**参考姿态**
+ (站立)的高度,各动作即共用同一本体尺寸。``fill_h`` 默认 0.62,给举过头顶留出余量。
+
+ ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把
+ 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。
+
+ ``cell``/``cell_h``:交付画布的宽与高,``cell_h=None`` 即方形 ``cell×cell``(默认,
+ 行为与加这个参数之前逐像素相同)。**要能出非方形画布,是为了让引擎一次就出到项目
+ 要的 sprite 尺寸、不必在上层再缩一次。** 上层那次二次缩放不是"糊一点"那么简单:
+ 它用 ``Image.thumbnail`` 补边,而 thumbnail **只缩不放** —— 项目要 512 时 256 的帧
+ 根本不会被放大,而是原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线(0.92)被挪到
+ 0.709(2026-08-11 实测),角色不站在地上了,跨动作对齐也一起失效。
+
+ 几何按"比例"而不是"像素"表达(``foot_line``/``fill_h``/``fill_w`` 都是比例),所以
+ 换画布尺寸不改变构图,母版入口预检(``master_check.REJECT_ASPECT`` = 2*FILL_W/FILL_H)
+ 与出帧仍共用同一套几何 —— 那条阈值里没有 cell,本来就与画布像素尺寸无关。
+ """
+ import numpy as np
+
+ cw = cell
+ ch = cell if cell_h is None else cell_h
+ if cw < 1 or ch < 1:
+ # 不静默出一张 0×0:PIL 允许建 0 边长的图,后面 alpha_composite 也不报错,
+ # 错产物要到落库/前端才暴露。
+ raise ValueError(f"交付画布尺寸必须为正,收到 cell={cell} cell_h={cell_h}")
+
+ boxes: list[tuple[int, int, int, int] | None] = []
+ for f in frames:
+ ys, xs = np.where(np.asarray(f)[:, :, 3] > 128)
+ boxes.append(
+ (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)
+ if len(ys)
+ else None
+ )
+ heights = [b[3] - b[1] for b in boxes if b]
+ if not heights:
+ return [Image.new("RGBA", (cw, ch), (0, 0, 0, 0)) for _ in frames]
+ # 定标一律按**本体**跨度,不按包围盒:后者被延展物撑大,而延展物幅度随动作变。
+ # 逐帧补偿要按帧号索引系数,故先留一份与 frames 等长、空帧为 None 的原始表。
+ core_spans = [core_span(f) for f in frames]
+ spans = [s for s in core_spans if s is not None]
+ # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量
+ ground = max(b[3] for b in boxes if b) if preserve_lift else 0
+ # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉
+ if preserve_lift:
+ need = max((ground - b[3]) + (b[3] - b[1]) for b in boxes if b)
+ scale = (ch * fill_h) / max(1, need)
+ elif ref_height:
+ scale = (ch * fill_h) / ref_height # 参考姿态定标(跨动作一致)
+ elif spans:
+ scale = (ch * fill_h) / max(1.0, float(np.median([s[0] for s in spans])))
+ else:
+ scale = (ch * fill_h) / max(heights) # 回退:本序列最高帧
+
+ # 宽度上限。两个目标本身是冲突的 —— 延展物越宽,"整帧不越界"就把角色压得越小,
+ # 而那恰恰是本函数要消除的忽大忽小。所以不做全局取舍,按**溢出量**分档:
+ #
+ # 溢出比 = 整帧宽 / 本体宽。它可量,也正好区分开三种情况:
+ # ≤ 1 + EXTREMITY_SLACK 贴身延展物(人形无披风、龙收翼)。整帧本来就装得下,
+ # 直接按本体定标,两个目标不冲突。
+ # 中间档 延展物明显但不夸张。让整帧装进画布 —— 此时压缩幅度有限,
+ # 尺寸偏差还在可接受范围,不值得为它丢像素。
+ # > EXTREMITY_CLIP_AT 延展物远大于本体(展翅、大甩尾)。**保尺寸一致**,让翅尖
+ # 溢出被裁 —— 再压下去角色本体会小到另一个动作的一半,
+ # 那比翅尖缺一点严重得多。
+ #
+ # 关键是最后这档**不静默**:裁掉多少写进日志,让"丢了像素"可见而不是靠人看图发现。
+ core_w = [s[1] for s in spans] or [b[2] - b[0] for b in boxes if b]
+ full_w = [b[2] - b[0] for b in boxes if b]
+ max_core, max_full = max(1.0, max(core_w)), max(1.0, max(full_w))
+ scale = min(scale, (cw * fill_w) / max_core)
+
+ # 整帧装不下时**不为它压缩角色**,让延展物溢出被裁。
+ #
+ # 这两个目标本身冲突:延展物越宽,"装进画布"就把角色压得越小,而那正是本函数要消除的
+ # 忽大忽小。选保尺寸,因为后果不对称 —— 压缩会让同一只角色在两个动作里差到 4 成
+ # (实测龙张翼 59%、鸟展翅 59%),而溢出只丢掉翅尖尾尖那几列像素。
+ #
+ # 试过折中("压缩量小于某个下限时就压"),**没有中间档**:实测 fit/scale 从 1.042 直接
+ # 跳到 0.961,跨过了任何合理的窗口。一个永不成立的分支比没有分支更坏。
+ #
+ # 关键是**不静默**:裁掉多少写进日志,让丢像素可见,而不是靠人看图发现。
+
+ # 逐帧补偿单调漂移。整段共用的 scale 只决定平均尺寸,趋势项由这里除掉;
+ # 补偿系数以 1.0 为中心,故平均尺寸与跨动作口径都不变。
+ # 空帧照常传给 scale_drift(它按帧号拟合、空位给 1.0)—— 少一个观测不该让整段不补。
+ per_frame = [1.0] * len(frames)
+ comp, ratio = scale_drift(
+ [s[0] if s is not None else None for s in core_spans],
+ [s[1] if s is not None else None for s in core_spans],
+ )
+ if any(c != 1.0 for c in comp):
+ per_frame = [1.0 / c for c in comp]
+ _logger.info(
+ "整段尺度单调漂移 %.1f%%(i2v 推镜),已逐帧补偿;补偿区间 %.3f–%.3f",
+ ratio * 100, min(per_frame), max(per_frame),
+ )
+
+ if max_full * scale > cw:
+ _logger.info(
+ "保尺寸一致而不压缩:整帧需 %.0fpx、画布 %dpx,两侧各溢出约 %.0fpx",
+ max_full * scale, cw, (max_full * scale - cw) / 2,
+ )
+
+ out = []
+ for idx, (f, box) in enumerate(zip(frames, boxes)):
+ if box is None:
+ out.append(Image.new("RGBA", (cw, ch), (0, 0, 0, 0)))
+ continue
+ crop = f.crop(box)
+ fs = scale * per_frame[idx]
+ w = max(1, round(crop.width * fs))
+ h = max(1, round(crop.height * fs))
+ crop = crop.resize((w, h), Image.NEAREST)
+ lift = round((ground - box[3]) * fs) if preserve_lift else 0
+ canvas = Image.new("RGBA", (cw, ch), (0, 0, 0, 0))
+ canvas.alpha_composite(crop, (cw // 2 - w // 2, int(ch * foot_line) - h - lift))
+ out.append(canvas)
+ return out
+
+
+def sprite_sheet(frames: list[Image.Image], bg=(0, 0, 0, 0)) -> Image.Image:
+ """横向拼接为 sprite sheet。"""
+ if not frames:
+ raise ValueError("frames 为空")
+ w, h = frames[0].size
+ sheet = Image.new("RGBA", (w * len(frames), h), bg)
+ for i, f in enumerate(frames):
+ sheet.alpha_composite(f.convert("RGBA"), (i * w, 0))
+ return sheet
+
+
+def save_gif(frames: list[Image.Image], path: str, duration: int = 120) -> None:
+ """导出循环 gif 供预览。"""
+ if not frames:
+ raise ValueError("frames 为空")
+ rgba = [f.convert("RGBA") for f in frames]
+ rgba[0].save(path, save_all=True, append_images=rgba[1:], duration=duration, loop=0, disposal=2)
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py
new file mode 100644
index 00000000..4910fa96
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py
@@ -0,0 +1,252 @@
+"""像素化后处理:把生成帧转成脆边限色的像素精灵。
+
+视频路线实测(Issue #35):
+- i2v 能解决步态(腿真交替、不转身);对**插画风**角色它保留插画质感 → 需要像素化转风格。
+- 对**原生像素**角色 i2v 其实能保住像素感,但链路上两道有损压缩(首帧 JPG q90 + 视频 H.264)
+ 会在硬边处产生振铃噪点(表现为灰颗粒),像素越细越明显;而通用的"降采样 + 32 色量化"
+ 因为**网格对不齐**反而更糊。
+- 解法:有母版时按 :func:`master_pixel_spec` 量出母版的**原生像素块大小**与**真实色板**,
+ 按母版网格降采样 + 颜色吸附回母版色板 —— 压缩灰颗粒不属于色板,会被强制消掉。
+
+纯 Pillow / numpy,零 API、秒级,符合"本机只做轻量 CV"的算力约束。
+输入约定:RGBA 图(alpha 为主体掩码,抠图见 framework 的 MatteProvider / Issue #20)。
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = [
+ "to_pixel_art",
+ "pixelate_frames",
+ "detect_pixel_size",
+ "extract_palette",
+ "master_pixel_spec",
+]
+
+
+def _content_bbox(rgba: Image.Image, alpha_thr: int = 128) -> tuple[int, int, int, int]:
+ """求主体包围盒。
+
+ 用 :func:`_subject_mask` 而非只看 alpha:母版常是**不透明白底**,只看 alpha 会把整张
+ 画布当主体,导致逻辑像素高被算成整图高而非角色高(实测踩过)。
+ """
+ mask = _subject_mask(rgba.convert("RGBA"), alpha_thr)
+ ys, xs = np.where(mask)
+ if len(ys):
+ return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
+ return 0, 0, rgba.width, rgba.height
+
+
+def _axis_block_size(crop: np.ndarray, axis: int, min_delta: int, min_frac: float) -> int:
+ """沿 ``axis`` 估块边长:显著色变位置 → 合并相邻 → 取最常见间距。"""
+ d = np.abs(np.diff(crop, axis=axis)).sum(axis=2)
+ frac = (d > min_delta).mean(axis=1 - axis)
+ edges = np.flatnonzero(frac > min_frac) + 1
+ if len(edges) < 3:
+ return 1
+ # 块边界常因轻微抗锯齿占相邻两行/列,合并成一条,否则 gap=1 会淹没真实值
+ edges = edges[np.concatenate([[True], np.diff(edges) > 1])]
+ gaps = np.diff(edges)
+ gaps = gaps[gaps >= 2]
+ return int(np.bincount(gaps).argmax()) if len(gaps) else 1
+
+
+def detect_pixel_size(
+ img: Image.Image, min_delta: int = 30, min_frac: float = 0.02, max_size: int = 64
+) -> int:
+ """检测像素画的原生像素块边长(非像素画/检测不出时返回 1)。
+
+ 原理:像素画的色块边界落在同一网格上,相邻边界间距 = 块边长的整数倍,故取
+ **最常见间距**即块边长。两轴分别估,取较小者(更保守,宁可细不可糊)。
+ """
+ rgba = img.convert("RGBA")
+ x0, y0, x1, y1 = _content_bbox(rgba)
+ crop = np.asarray(rgba.crop((x0, y0, x1, y1)).convert("RGB")).astype(np.int16)
+ if crop.size == 0:
+ return 1
+ sizes = [_axis_block_size(crop, ax, min_delta, min_frac) for ax in (0, 1)]
+ best = min(s for s in sizes) if all(s >= 1 for s in sizes) else 1
+ return max(1, min(best, max_size))
+
+
+def _erode(mask: np.ndarray, k: int) -> np.ndarray:
+ """二值腐蚀 k 次(纯 numpy 移位,不引 scipy)。"""
+ m = mask
+ for _ in range(max(0, k)):
+ m = (
+ m
+ & np.roll(m, 1, 0)
+ & np.roll(m, -1, 0)
+ & np.roll(m, 1, 1)
+ & np.roll(m, -1, 1)
+ )
+ if not m.any():
+ return mask
+ return m
+
+
+def _subject_mask(
+ rgba: Image.Image, alpha_thr: int = 128, bg_tol: int = 40, erode: int = 0
+) -> np.ndarray:
+ """主体掩码:优先用真实 alpha;母版常是**不透明白底**,此时按四角背景色排除背景。
+
+ 两个实测踩过的坑:
+ 1. 不排背景 → 白底占多数像素、吃光色板名额 → 角色被整体吸附成白色。
+ 2. 排了背景但保留边缘 → 角色/白底之间的**抗锯齿过渡色**(近白)混进色板 →
+ 视频里的浅色噪点就近吸附成白点,满身白斑。故取色板时用 ``erode`` 腐蚀掉边缘。
+ """
+ arr = np.asarray(rgba)
+ alpha = arr[:, :, 3]
+ if not alpha.min() > alpha_thr: # 有真实抠图
+ mask = alpha > alpha_thr
+ else:
+ rgb = arr[:, :, :3].astype(np.int16)
+ corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]])
+ bg = np.median(corners, axis=0)
+ mask = np.abs(rgb - bg).sum(axis=2) > bg_tol
+ return _erode(mask, erode)
+
+
+def extract_palette(
+ img: Image.Image, max_colors: int = 32, alpha_thr: int = 128, erode: int = 3
+) -> np.ndarray:
+ """提取母版真实色板,返回 (K,3) uint8。
+
+ 只统计主体像素(见 :func:`_subject_mask`,并腐蚀掉抗锯齿边缘),再用中位切分量化
+ 归并噪声色 —— 生成的"像素画"常带轻微噪点/抗锯齿,同一名义色被打散成大量近似色,
+ 直接按频率统计会全被当杂色滤掉。
+ """
+ rgba = img.convert("RGBA")
+ arr = np.asarray(rgba)
+ mask = _subject_mask(rgba, alpha_thr, erode=erode)
+ pixels = arr[:, :, :3][mask]
+ if not len(pixels):
+ pixels = arr[:, :, :3].reshape(-1, 3)
+ strip = Image.fromarray(pixels.reshape(1, -1, 3).astype(np.uint8), "RGB")
+ quant = strip.quantize(colors=max(2, max_colors), method=Image.MEDIANCUT)
+ pal = np.asarray(quant.getpalette()[: max(2, max_colors) * 3], dtype=np.uint8).reshape(-1, 3)
+ used = np.unique(np.asarray(quant))
+ return pal[used[used < len(pal)]]
+
+
+def master_pixel_spec(master: Image.Image, max_colors: int = 48) -> tuple[int, np.ndarray]:
+ """从母版量出 (角色的逻辑像素高, 母版色板)。
+
+ 逻辑像素高 = 母版里角色占的像素行数 ÷ 原生像素块边长 —— 即"这个角色本来是多少
+ 像素高的精灵"。用它当 ``target_h`` 可自动吸附网格,不必人肉猜分辨率。
+
+ ``max_colors`` 实测取值:32 太少 —— 中位切分按面积分箱,大面积色(如裸腿肤色/棕靴)
+ 会挤占名额,小面积但需渐变的衣服色档位不足 → 中间调就近吸到邻近色相(绿衣泛橄榄黄);
+ 96 太多 —— 抗锯齿近白色重新拿到独立分箱 → 边缘冒白噪点。48 是实测的安全区。
+ """
+ x0, y0, x1, y1 = _content_bbox(master.convert("RGBA"))
+ block = detect_pixel_size(master)
+ logical_h = max(1, round((y1 - y0) / block))
+ return logical_h, extract_palette(master, max_colors=max_colors)
+
+
+def _to_perceptual(rgb: np.ndarray) -> np.ndarray:
+ """RGB → 近似感知空间(亮度 + 两个色差轴),float32。
+
+ 直接在 RGB 里取最近邻会**跳色相**:绿衣的中间调可能被吸到橄榄黄(实测踩过)。
+ 换成亮度/色差轴并给色差加权后,同色相内的明暗过渡优先匹配,色相跳变被压住。
+ 这里用 YCbCr 型线性变换(比 Lab 便宜得多,足够拉开色相)。
+ """
+ f = rgb.astype(np.float32)
+ r, g, b = f[..., 0], f[..., 1], f[..., 2]
+ y = 0.299 * r + 0.587 * g + 0.114 * b
+ cb = b - y
+ cr = r - y
+ w = 2.0 # 色差权重 >1:宁可亮度差一点,也别换色相
+ return np.stack([y, w * cb, w * cr], axis=-1)
+
+
+def _snap_to_palette(rgb: np.ndarray, palette: np.ndarray) -> np.ndarray:
+ """把每个像素吸附到色板中最近的颜色(感知空间最近邻,分块避免大内存)。
+
+ 用 float32 感知空间:①避免 int16 平方距离溢出(255² > 32767,实测让绿衣变肉色);
+ ②按色相优先匹配,防止 RGB 空间里的跨色相跳变。
+ """
+ flat = _to_perceptual(rgb).reshape(-1, 3)
+ pal_p = _to_perceptual(palette).reshape(-1, 3)
+ pal_rgb = palette.astype(np.uint8).reshape(-1, 3)
+ out = np.empty((len(flat), 3), dtype=np.uint8)
+ step = 65536
+ for i in range(0, len(flat), step):
+ chunk = flat[i : i + step]
+ d = ((chunk[:, None, :] - pal_p[None, :, :]) ** 2).sum(axis=2)
+ out[i : i + step] = pal_rgb[d.argmin(axis=1)]
+ return out.reshape(rgb.shape)
+
+
+def to_pixel_art(
+ rgba: Image.Image,
+ target_h: int = 100,
+ palette_size: int = 32,
+ alpha_thr: int = 128,
+ palette: np.ndarray | None = None,
+) -> Image.Image:
+ """单帧转像素风,返回小尺寸 RGBA(``target_h`` 高,等比宽)。
+
+ 步骤:裁到主体包围盒 → 等比缩到 ``target_h``(NEAREST 网格降采样)→ 限色。
+ 限色两种模式:
+ - ``palette`` 给定(推荐,原生像素角色):**吸附到母版真实色板**,顺带消掉
+ JPG/H.264 在硬边留下的灰颗粒。
+ - ``palette=None``(插画转像素):按 ``palette_size`` 做八叉树量化。
+
+ Args:
+ target_h: 目标像素高;原生像素角色建议用 :func:`master_pixel_spec` 算出的逻辑高。
+ palette_size: 无母版色板时的量化色数。
+ palette: (K,3) uint8 母版色板。
+ """
+ if target_h < 1:
+ raise ValueError("target_h 必须 >= 1")
+ rgba = rgba.convert("RGBA")
+ x0, y0, x1, y1 = _content_bbox(rgba, alpha_thr)
+ crop = rgba.crop((x0, y0, x1, y1))
+ w, h = crop.size
+ target_w = max(1, round(w * target_h / h))
+ small = crop.resize((target_w, target_h), Image.NEAREST)
+
+ alpha = np.asarray(small)[:, :, 3]
+ if palette is not None and len(palette):
+ rgb = _snap_to_palette(np.asarray(small.convert("RGB")), palette)
+ else:
+ rgb = np.asarray(
+ small.convert("RGB")
+ .quantize(colors=max(2, palette_size), method=Image.FASTOCTREE)
+ .convert("RGB")
+ )
+ out = np.dstack([rgb, alpha]).astype(np.uint8)
+ return Image.fromarray(out, "RGBA")
+
+
+def pixelate_frames(
+ frames: list[Image.Image],
+ target_h: int = 100,
+ palette_size: int = 32,
+ palette: np.ndarray | None = None,
+ ref_height: float | None = None,
+) -> list[Image.Image]:
+ """批量像素化一组帧,**整段共用一个缩放系数**,便于打包为 sprite sheet。
+
+ ``target_h`` 是**基准姿态**的目标像素高,其余帧按同一系数等比缩放 —— 不是把每帧都拉
+ 到等高。逐帧拉等高会把走路自然的身高起伏反向变成"忽大忽小"(实测踩过:蹲下的帧被放大)。
+
+ ``ref_height``:**跨动作一致性的关键**。给定时用它当基准(单位=源图像素),否则用本序列
+ 最高帧。同一角色的各个动作若各自取自己的最高帧定标,切换状态时角色会忽大忽小 ——
+ 传入同一个基准(如母版姿态的角色高)即可让 idle/walk/jump/attack 共用一套尺度。
+ """
+ if not frames:
+ return []
+ box_h = []
+ for f in frames:
+ _, y0, _, y1 = _content_bbox(f.convert("RGBA"))
+ box_h.append(max(1, y1 - y0))
+ scale = target_h / (ref_height if ref_height else max(box_h))
+ return [
+ to_pixel_art(f, max(1, round(h * scale)), palette_size, palette=palette)
+ for f, h in zip(frames, box_h)
+ ]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py
new file mode 100644
index 00000000..a8110d09
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py
@@ -0,0 +1,70 @@
+"""Root motion(位移轨迹)与逐帧时长 —— 按 2D 游戏业界惯例分离"姿势"与"位移"。
+
+业界做法(调研 2026-07-28):
+- **位移不烘进序列帧**。连续位移动作几乎一律用 *in-place animation + 引擎代码驱动移动*,
+ 因为玩家要即时操控:跑动中转向应立刻响应,而不是等一段烘死的位移播完。平台游戏的跳跃
+ 也是"几个姿势定格 + 引擎物理驱动上下",不是把抛物线画进像素。
+ → 序列帧保持**原地**(脚线对齐),位移单独作为 root-motion 轨道交给引擎。
+- **逐帧时长比帧数更重要**("frame timing beats frame count")。业界常用:
+ idle 400–500ms/帧、walk 100–150ms、run 80–100ms、attack 起手 80–100ms 且**触点定格
+ 150–200ms**。全程等时长会让动作发飘、没有重量感。
+
+本模块只做几何与时长计算,纯 numpy,零 API。
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = ["extract_root_motion", "frame_durations", "DEFAULT_FPS_MS"]
+
+# 各动作的基准单帧时长(ms),取业界常用区间的中值。
+DEFAULT_FPS_MS = {
+ "idle": 450,
+ "walk": 125,
+ "run": 90,
+ "jump": 110,
+ "attack": 90,
+ "hit": 90,
+ "custom": 90,
+}
+
+
+def extract_root_motion(frames: list[Image.Image], alpha_thr: int = 128) -> list[tuple[int, int]]:
+ """逐帧相对首帧的 (dx, dy) 位移,单位=像素,y 向上为正。
+
+ 以主体包围盒的**底边中心**(脚点)为参考点。序列帧本身保持原地时,这条轨道就是引擎
+ 要施加的 root motion:jump 的 dy 是腾空高度,walk 的 dx 是前进量。
+ """
+ pts: list[tuple[float, float]] = []
+ for f in frames:
+ a = np.asarray(f.convert("RGBA"))
+ ys, xs = np.where(a[:, :, 3] > alpha_thr)
+ pts.append(((xs.min() + xs.max()) / 2, float(ys.max())) if len(ys) else (np.nan, np.nan))
+ arr = np.array(pts, dtype=np.float32)
+ if np.isnan(arr).any(): # 空帧用邻近值补
+ idx = np.arange(len(arr))
+ for c in range(2):
+ good = ~np.isnan(arr[:, c])
+ arr[:, c] = np.interp(idx, idx[good], arr[good, c]) if good.any() else 0.0
+ base = arr[0]
+ return [(int(round(p[0] - base[0])), int(round(base[1] - p[1]))) for p in arr]
+
+
+def frame_durations(
+ action: str, n_frames: int, key_frame: int | None = None, hold_ms: int = 180
+) -> list[int]:
+ """逐帧时长(ms)。关键帧(触点 / 顶点)加长定格,其余用该动作的基准时长。
+
+ Args:
+ action: 动作名(取 :data:`DEFAULT_FPS_MS` 的基准时长,未知动作按 walk)。
+ n_frames: 帧数。
+ key_frame: 要定格的帧下标(attack 的触点、jump 的顶点);None 表示全程等时长。
+ hold_ms: 关键帧时长,业界常用 150–200ms。
+ """
+ base = DEFAULT_FPS_MS.get(action, DEFAULT_FPS_MS["walk"])
+ out = [base] * max(0, n_frames)
+ if key_frame is not None and 0 <= key_frame < n_frames:
+ out[key_frame] = max(base, hold_ms)
+ return out
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/prompt/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py
new file mode 100644
index 00000000..ae7f12da
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py
@@ -0,0 +1,16 @@
+"""prompt:各动作的生成提示词与装配。"""
+
+from .actions import build_attack_prompt, build_idle_prompt
+from .custom import MAX_ACTION_CHARS, build_custom_prompt
+from .jump import JUMP_PHASES, build_jump_prompt
+from .walk import build_walk_prompt
+
+__all__ = [
+ "build_walk_prompt",
+ "JUMP_PHASES",
+ "build_jump_prompt",
+ "build_idle_prompt",
+ "build_attack_prompt",
+ "build_custom_prompt",
+ "MAX_ACTION_CHARS",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py
new file mode 100644
index 00000000..02966988
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py
@@ -0,0 +1,21 @@
+"""所有动作共用的构图约束。
+
+由代码统一追加而不是抄进每份 md:同一条约束抄 N 份会各自漂移。
+
+只写正向计数句 —— 该 i2v 接口没有 negative_prompt,否定句里的名词会被 latch 进画面
+(实测"do not add dust"反而勾出更多灰尘),所以说"恰好一个",不说"不要第二个"。
+"""
+from __future__ import annotations
+
+__all__ = ["SINGLE_SUBJECT_FRAMING", "with_framing"]
+
+# 攻击的两处留白(母版姿态要求 + 母版补边)让画面空得足以容下第二个主体。
+SINGLE_SUBJECT_FRAMING = (
+ "Exactly one character is in the frame, alone against a plain flat solid-color background, "
+ "and the whole body stays inside the frame."
+)
+
+
+def with_framing(body: str) -> str:
+ """给一段动作正文接上构图约束。"""
+ return f"{body} {SINGLE_SUBJECT_FRAMING}"
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_md.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_md.py
new file mode 100644
index 00000000..9cadf8a2
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_md.py
@@ -0,0 +1,78 @@
+"""提示词 markdown 的加载器。
+
+正文住在 md 而不是 Python 常量:它是数据不是代码,改措辞不该动代码。
+
+**缺文件 / 缺节 / 空节一律抛错。** 静默返回空串会让付费调用照常发出、产出垃圾、
+任务还显示成功。
+"""
+from __future__ import annotations
+
+import re
+from functools import lru_cache
+from importlib import resources
+
+__all__ = ["PromptAssetError", "load_section", "load_doc"]
+
+_PKG = "windup_ai_engine.prompt.prompts"
+
+# 节名不许有空格:它是被代码按字面量索引的键。
+_HEADING = re.compile(r"^##[ \t]+(\S+)[ \t]*$", re.MULTILINE)
+
+# 正文必须在围栏代码块里,框外的是说明、不进产物。
+_FENCE = re.compile(r"^```[^\n]*\n(.*?)^```", re.S | re.M)
+
+
+class PromptAssetError(RuntimeError):
+ """提示词资产读不出来。"""
+
+
+@lru_cache(maxsize=None)
+def load_doc(name: str) -> dict[str, str]:
+ """读一份提示词 md,返回 ``{节名: 正文}``。"""
+ try:
+ raw = resources.files(_PKG).joinpath(name).read_text(encoding="utf-8")
+ except (FileNotFoundError, ModuleNotFoundError, OSError) as e:
+ raise PromptAssetError(f"读不到提示词资产 {_PKG}/{name}:{e!r}") from e
+
+ marks = list(_HEADING.finditer(raw))
+ if not marks:
+ raise PromptAssetError(
+ f"提示词资产 {name} 里一个 `## <节名>` 都没有。期望:每个朝向一节,"
+ "节下用一个 ```text 代码块装正文。"
+ )
+ out: dict[str, str] = {}
+ for i, m in enumerate(marks):
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(raw)
+ out[m.group(1)] = _fenced(raw[m.end():end], name, m.group(1))
+ return out
+
+
+def _fenced(block: str, doc: str, section: str) -> str:
+ """取代码块正文并折成一行。
+
+ 折行是因为 md 里按句子换行只为可读,带着换行送出去与校准过的那版就不是同一个字符串。
+ """
+ fences = _FENCE.findall(block)
+ if not fences:
+ return ""
+ if len(fences) > 1:
+ raise PromptAssetError(
+ f"{doc} 的 `## {section}` 节里有 {len(fences)} 个代码块,只能有一个。"
+ )
+ return " ".join(ln.strip() for ln in fences[0].splitlines() if ln.strip())
+
+
+def load_section(name: str, section: str, *, allow_empty: bool = False) -> str:
+ """取某份 md 的某一节。
+
+ ``allow_empty`` 只给空本身有含义的地方用(``MASTER_POSES``)。
+ """
+ doc = load_doc(name)
+ if section not in doc:
+ raise PromptAssetError(
+ f"提示词资产 {name} 里没有 `## {section}` 这一节。现有:{sorted(doc)}"
+ )
+ text = doc[section]
+ if not text and not allow_empty:
+ raise PromptAssetError(f"提示词资产 {name} 的 `## {section}` 节是空的。")
+ return text
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py
new file mode 100644
index 00000000..d615ce23
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py
@@ -0,0 +1,34 @@
+"""待机 / 攻击 i2v 提示词。
+
+提示词正文在 ``prompts/idle.md`` 与 ``prompts/attack.md``(#233)。
+本模块只留加载与按 facing / archetype 分流。
+"""
+from __future__ import annotations
+
+from windup_common.models import AttackArchetype, Facing
+
+from windup_ai_engine.prompt._framing import with_framing
+from windup_ai_engine.prompt._md import load_section
+
+__all__ = ["build_idle_prompt", "build_attack_prompt"]
+
+
+def build_idle_prompt(facing: Facing | str = Facing.SIDE) -> str:
+ """待机正文(循环类)。``facing`` 须与母版朝向一致。
+
+ """
+ return with_framing(load_section("idle.md", Facing(facing).value))
+
+
+def build_attack_prompt(
+ facing: Facing | str = Facing.SIDE,
+ *,
+ archetype: AttackArchetype | str = AttackArchetype.THRUST,
+) -> str:
+ """攻击正文(一次性类)。``facing`` 须与母版朝向一致。
+
+ 默认取 THRUST:四支里只有 SWEEP 要求手里有一件有宽面的长条物,拿它当默认 = 对每个未知角色断言持械(#195)。
+ """
+ # 两个枚举都过一遍构造:非法值要炸,不能静默落到某一节。
+ section = f"{AttackArchetype(archetype).value}.{Facing(facing).value}"
+ return with_framing(load_section("attack.md", section))
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py
new file mode 100644
index 00000000..33ba36d9
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py
@@ -0,0 +1,73 @@
+"""自定义动作 i2v 提示词(#239)。
+
+动作内容来自用户,本模块只提供骨架。骨架负责四件用户那句话给不了的事:朝向锁、只写正向词
+(该接口无 negative_prompt,否定词里的名词会被 latch 进画面)、装备存在无关句(#195)、
+一次性动作的单次 + 终态保持。
+"""
+
+from __future__ import annotations
+
+from windup_common.models import Facing
+
+from windup_ai_engine.prompt._framing import with_framing
+
+__all__ = ["build_custom_prompt", "MAX_ACTION_CHARS"]
+
+# 不是接口限制,是产品判断:描述越长越容易夹带角色外观,而外观由母版承载,写两遍会打架。
+MAX_ACTION_CHARS = 200
+
+_FACING_LOCK = {
+ Facing.SIDE: (
+ "seen from the side facing right, staying in SIDE VIEW facing right the whole time, "
+ "the torso and hips keep pointing to the right"
+ ),
+ Facing.FRONT: (
+ "facing the viewer, the character keeps FACING THE VIEWER the whole time "
+ "and stays centered in frame"
+ ),
+}
+
+# 存在无关:锁住"别乱动",但不断言角色有什么(#195)。
+_KEEP_WHAT_IT_HAS = (
+ "whatever the character already wears or carries keeps its own shape and moves with the body, "
+ "anything held in the hands stays in the same grip at the same angle"
+)
+
+# 两条尾句都刻意不写"在地面上 / 双脚可见 / 回到直立站姿"——那些是着地直立类动作的前提,
+# 游泳、飞行、攀爬、倒地都不成立,而文字与动作矛盾时模型会自己找辙调和。
+_CYCLIC_TAIL = (
+ "The motion is one smooth repeating cycle that returns to its starting pose, "
+ "and the character stays centered in the same spot in frame."
+)
+_ONESHOT_TAIL = (
+ "The character performs this ONCE as one single committed motion, "
+ "then holds the final pose and stays still."
+)
+
+
+def build_custom_prompt(
+ action: str,
+ *,
+ facing: Facing | str = Facing.SIDE,
+ cyclic: bool = False,
+) -> str:
+ """把用户自述的动作嵌进骨架。
+
+ Args:
+ action: 动作内容(如 "waves the right hand above the head")。只写做什么动作。
+ facing: 母版朝向。**必须与母版一致**,否则模型靠转身调和矛盾。
+ cyclic: 是否循环。与 slicing 走 pick_cycle / pick_oneshot 同一口径。
+
+ Raises:
+ ValueError: 描述为空或超长。空描述不兜底默认动作——那会付一次 i2v 的钱拿到一段
+ 站着不动的视频,而帧数时长全对、看不出描述丢了。
+ """
+ text = (action or "").strip()
+ if not text:
+ raise ValueError("自定义动作的描述不能为空")
+ if len(text) > MAX_ACTION_CHARS:
+ raise ValueError(f"自定义动作描述 {len(text)} 字,超过上限 {MAX_ACTION_CHARS}")
+ lock = _FACING_LOCK[Facing(facing)] # 非法朝向要炸,不静默落到某一支
+ tail = _CYCLIC_TAIL if cyclic else _ONESHOT_TAIL
+ # 朝向放最前:最强的约束先钉。
+ return with_framing(f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}. {tail}")
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py
new file mode 100644
index 00000000..6aef07bb
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py
@@ -0,0 +1,27 @@
+"""跳跃 i2v 提示词(一次性动作,非循环)。
+
+提示词正文在 ``prompts/jump.md``(#233)。本模块只留状态表、加载与分流。
+"""
+from __future__ import annotations
+
+from windup_common.models import Facing
+
+from windup_ai_engine.prompt._framing import with_framing
+from windup_ai_engine.prompt._md import load_section
+
+__all__ = ["JUMP_PHASES", "build_jump_prompt"]
+
+_DOC = "jump.md"
+
+# 顺序即时间顺序。
+JUMP_PHASES = ("crouch", "rise", "apex", "fall", "land")
+
+
+def build_jump_prompt(facing: Facing | str = Facing.SIDE) -> str:
+ """按母版朝向生成跳跃正文。
+
+ Args:
+ facing: :class:`Facing` 成员(或其等价字符串),**必须与母版朝向一致**。
+
+ """
+ return with_framing(load_section(_DOC, Facing(facing).value))
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md
new file mode 100644
index 00000000..d46662ae
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md
@@ -0,0 +1,96 @@
+# 攻击 i2v 提示词(一次性类)
+
+节名是 `<运动拓扑>.<朝向>`。分支依据是**身体怎么发力**,不是手里拿着什么:
+写"宽面""弧线"这类形状词等于断言角色手握一件有宽面的长条物,喂法杖 / 空手 / 四足角色时
+模型会凭空补出那件东西来调和图文矛盾(与 #195 同一个坑,只是从名词层退到形状层)。
+
+四支都要写整体位移(whole body / torso / hips):i2v 强跟身体、弱跟持物,
+只描述持物的运动会让它自行漂移。
+
+## sweep.side
+
+```text
+Seen from the side facing right, the character makes ONE single committed strike, staying in STRICT SIDE VIEW the whole time:
+starting coiled with the weight on the back foot, the whole body uncoils and the hips drive forward as the weight surges onto the front foot,
+the striking side of the body travelling in one continuous path from far behind the body, down across the front of the torso, out to full extension low in front,
+whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle,
+then the torso settles back upright into guard and holds that stance.
+The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer.
+```
+
+## sweep.front
+
+```text
+Facing the viewer, the character makes ONE single committed strike: starting coiled with the weight on the back foot,
+the whole body uncoils forward and the hips turn into the motion as the weight surges onto the front foot,
+the striking side of the body travelling in one continuous path across the front of the torso out to full extension,
+whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle,
+then the torso settles back upright into guard and holds that stance, standing steady and keeping FACING THE VIEWER.
+```
+
+## thrust.side
+
+```text
+Seen from the side facing right, the character drives ONE single committed strike straight forward, staying in STRICT SIDE VIEW the whole time:
+starting coiled low with the weight on the back foot and the striking side pulled in at waist height,
+the hips snap forward and the whole body drives straight ahead as the weight lands on the front foot,
+the striking side of the body extending in one straight line directly forward to full reach and stopping there,
+whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle,
+then the torso draws back over the hips and settles into guard and holds that stance.
+The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer.
+```
+
+## thrust.front
+
+```text
+Facing the viewer, the character drives ONE single committed strike straight toward the viewer:
+starting coiled low with the weight on the back foot and the striking side pulled in at waist height,
+the hips snap forward and the whole body drives straight ahead as the weight lands on the front foot,
+the striking side of the body extending in one straight line directly toward the viewer to full reach and stopping there,
+whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle,
+then the torso draws back over the hips and settles into guard and holds that stance, standing steady and keeping FACING THE VIEWER.
+```
+
+## project.side
+
+```text
+Seen from the side facing right, the character makes ONE single committed ranged release, staying in STRICT SIDE VIEW the whole time:
+starting settled with the weight low over both feet, the torso presses forward over the front foot and the hips square up behind the motion,
+the releasing side of the body reaching straight out in front of the chest and coming to a firm stop at full extension,
+the whole body braced and steady at that moment,
+whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle,
+then the character keeps that extended pose and stays still.
+The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer.
+```
+
+## project.front
+
+```text
+Facing the viewer, the character makes ONE single committed ranged release toward the viewer:
+starting settled with the weight low over both feet, the torso presses forward and the hips square up behind the motion,
+the releasing side of the body reaching straight out in front of the chest toward the viewer and coming to a firm stop at full extension,
+the whole body braced and steady at that moment,
+whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle,
+then the character keeps that extended pose and stays still, keeping FACING THE VIEWER.
+```
+
+## lunge.side
+
+```text
+Seen from the side facing right, the character makes ONE single committed lunge forward, staying in STRICT SIDE VIEW the whole time:
+starting crouched low with the weight loaded onto the rear limbs, the whole body surges forward in one burst with the head and the leading limbs arriving first,
+the hips and torso following along that same line and the back stretching out level and low over the ground,
+whatever the character already wears or carries keeps its own shape and moves with the body,
+then the body gathers back under itself, settles low and holds that crouched stance.
+The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer.
+```
+
+## lunge.front
+
+```text
+Facing the viewer, the character makes ONE single committed lunge toward the viewer:
+starting crouched low with the weight loaded onto the rear limbs, the whole body surges forward in one burst with the head and the leading limbs arriving first,
+the hips and torso following along that same line and the back stretching out level and low over the ground,
+whatever the character already wears or carries keeps its own shape and moves with the body,
+then the body gathers back under itself, settles low and holds that crouched stance, keeping FACING THE VIEWER.
+```
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/idle.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/idle.md
new file mode 100644
index 00000000..251698e3
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/idle.md
@@ -0,0 +1,21 @@
+# 待机 i2v 提示词(循环类)
+
+## side
+
+```text
+The character stands in place, seen from the side facing right: the chest breathes in one slow,
+even rhythm, the ribcage expanding and easing back while the shoulders stay level and settled at the same height,
+the torso rising and lowering in that same slow rhythm, anything held in the hands resting steady at the side in the same grip,
+whatever the character already wears hanging and swaying in the same rhythm, both feet planted firmly on the ground,
+weight centered, the character stays in the same spot and keeps facing right.
+```
+
+## front
+
+```text
+The character stands in place facing the viewer: the chest breathes in one slow, even rhythm,
+the ribcage expanding and easing back while the shoulders stay level and settled at the same height,
+the torso rising and lowering in that same slow rhythm, anything held in the hands resting steady at the side in the same grip,
+whatever the character already wears hanging and swaying in the same rhythm, both feet planted firmly on the ground,
+weight centered, the character keeps FACING THE VIEWER and stays in the same spot.
+```
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/jump.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/jump.md
new file mode 100644
index 00000000..ae480b5f
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/jump.md
@@ -0,0 +1,23 @@
+# 跳跃 i2v 提示词(一次性动作,非循环)
+
+## side
+
+```text
+The character performs ONE single jump in place, seen from the side facing right: first the knees bend deep into a crouch and the arms drop back,
+then both feet push off the ground and the whole body lifts straight upward a modest height with the legs tucking up,
+the body reaches the top of the jump and hangs there for an instant with whatever the character already wears floating upward,
+then the body falls back down with the legs reaching for the ground, and both feet land together with the knees bending to absorb the impact,
+anything held in the hands stays in the same grip at the same angle the whole time. The character does this ONCE and then stays standing upright in the landing spot,
+staying centered in frame.
+```
+
+## front
+
+```text
+The character performs ONE single jump in place, facing the viewer: first the knees bend deep into a crouch and the arms drop back,
+then both feet push off the ground hard and the whole body launches straight upward with the knees tucking up toward the camera,
+the body reaches the top of the jump and hangs there for an instant with whatever the character already wears floating upward,
+then the body falls back down with the legs reaching for the ground, and both feet land together with the knees bending to absorb the impact,
+anything held in the hands stays in the same grip at the same angle the whole time. The character keeps FACING THE VIEWER,
+does this ONCE and then stays standing upright, centered in frame.
+```
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md
new file mode 100644
index 00000000..88acdb4e
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md
@@ -0,0 +1,51 @@
+# 各动作所需的母版姿态
+
+attack 按运动拓扑分四节(`attack.<拓扑>`):母版姿态决定动作、提示词只能微调,
+而四支的起手姿态互不兼容 —— 拿横挥蓄力母版跑直刺,模型会先把收在腰际的那侧重新抡起来。
+
+## walk
+
+## run
+
+## idle
+
+## jump
+
+```text
+deep crouch coiled to spring straight upward: the knees bent low and the hips sunk down, both arms drawn back behind the body,
+the weight loaded onto both legs at the very moment before springing straight up, anything held in the hands kept in a fixed grip; leave generous empty space above the head
+```
+
+## attack.sweep
+
+```text
+extreme wind-up stance for a horizontal strike: the striking hand drawn far BACK behind the body at WAIST height,
+the torso twisted back and coiled, weight fully loaded on the back leg, both arms low and pulled back,
+that hand and anything held in it staying BELOW the shoulders; leave generous empty space on the swing side
+```
+
+## attack.thrust
+
+```text
+low coiled stance ready to drive straight forward: the weight sunk onto the back leg with both knees bent,
+the striking side pulled in tight against the body at WAIST height and held there ready to fire,
+the torso squared low over the front foot, that side and anything held in it staying BELOW the shoulders;
+leave generous empty space in front
+```
+
+## attack.project
+
+```text
+braced stance ready to send something forward at a distance: both feet planted wide and firmly set,
+the hips sunk low and the weight centred between the feet, the torso upright and square,
+both hands drawn in close in front of the chest and held there, anything held in them kept in a fixed grip;
+leave generous empty space in front
+```
+
+## attack.lunge
+
+```text
+crouched stance coiled to spring forward: all four limbs folded under the body with the chest lowered close to the ground,
+the rear limbs deeply loaded and ready to drive, the head and the leading limbs pointing forward along the line of travel,
+anything the character carries kept in place; leave generous empty space in front
+```
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/walk.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/walk.md
new file mode 100644
index 00000000..883be63e
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/walk.md
@@ -0,0 +1,21 @@
+# 走路 i2v 提示词(视频路线)
+
+## side
+
+```text
+The character walks steadily to the right through the open space, the whole body advancing with every stride:
+the front foot lifts, swings forward and plants heel first, the rear foot pushes off the ground,
+the hips and torso carry the weight forward over the planted foot, whatever the character already wears or carries keeps its own shape and sways with the steps,
+anything held in the hands stays in the same grip at the same angle, the upper body stays calm and upright,
+SIDE VIEW facing right the whole time, the legs clearly visible.
+```
+
+## front
+
+```text
+The character walks in place toward the viewer, marching forward on the spot: each foot lifts,
+swings forward and plants down in turn while the other pushes off, the knees rise alternately toward the camera,
+the hips and shoulders sway naturally with each step, whatever the character already wears or carries keeps its own shape and sways with the steps,
+anything held in the hands stays in the same grip at the same angle, the upper body stays calm and upright,
+the character keeps FACING THE VIEWER the whole time and stays centered in frame, both legs clearly visible.
+```
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py
new file mode 100644
index 00000000..5f9a74fe
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py
@@ -0,0 +1,26 @@
+"""走路 i2v 提示词(视频路线)。
+
+提示词正文在 ``prompts/walk.md``(#233),本模块只留加载与按 facing 分流。
+措辞经过校准,**逐字改动前先查内部实验记录**。
+"""
+from __future__ import annotations
+
+from windup_common.models import Facing
+
+from windup_ai_engine.prompt._framing import with_framing
+from windup_ai_engine.prompt._md import load_section
+
+__all__ = ["build_walk_prompt"]
+
+_DOC = "walk.md"
+
+def build_walk_prompt(facing: Facing | str = Facing.SIDE) -> str:
+ """按母版朝向生成走路正文。
+
+ Args:
+ facing: :class:`Facing` 成员(或其等价字符串)。**必须与母版朝向一致**,
+ 否则模型会靠转身调和矛盾。
+
+ """
+ # 过一遍 Facing() 构造:非法值要炸,不能静默落到某个模板。
+ return with_framing(load_section(_DOC, Facing(facing).value))
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/slicing/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py
new file mode 100644
index 00000000..d1ce7d2e
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py
@@ -0,0 +1,38 @@
+"""slicing:视频 → 帧序列。抽帧(extract)+ 选帧(周期 loop / 一次性 oneshot)。
+
+视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝
+loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。
+
+:mod:`.quality` 原本纯做诊断,现在还兼一份出参职责:交付帧的成色读数
+(``motion_scale`` / ``dead_frame_indices`` / ``loop_seam`` / ``limb_motion``)汇成
+``ports.ActionQuality``。
+注意它**仍然不参与选帧** —— 那条消融结论没变,见 :func:`.loop.pick_cycle`。
+"""
+
+from .extract import extract_all_frames_bytes, extract_frames_bytes
+from .loop import find_period, pick_cycle
+from .oneshot import (
+ find_motion_span,
+ first_action_end,
+ foot_line_series,
+ pick_oneshot,
+ split_jump_phases,
+)
+from .quality import dead_frame_indices, limb_motion, loop_seam, motion_scale
+
+__all__ = [
+ "extract_frames_bytes",
+ "extract_all_frames_bytes",
+ "find_period",
+ "pick_cycle",
+ # 交付成色的四个读数(汇成 ports.ActionQuality;其余 quality.* 仍是内部诊断)
+ "dead_frame_indices",
+ "limb_motion",
+ "loop_seam",
+ "motion_scale",
+ "find_motion_span",
+ "first_action_end",
+ "foot_line_series",
+ "pick_oneshot",
+ "split_jump_phases",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py
new file mode 100644
index 00000000..c8e36f6e
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py
@@ -0,0 +1,41 @@
+"""选帧与帧质量共用的取样原语。
+
+``loop``(选帧)与 ``quality``(诊断)必须在**同一尺度**上看帧,否则两边算出的差异量
+不可比 —— 之前两处各自持有一份 ``_gray`` 与 ``_SMALL``,调一边不会波及另一边,
+是一个只会在数据上体现、不会报错的隐患。此处收成唯一定义。
+"""
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+__all__ = ["SMALL", "gray", "alpha_stack"]
+
+# 帧比对统一降采样到 48×48 灰度:够分辨姿态差异,又让全帧对距离矩阵的开销可接受。
+SMALL = 48
+
+
+def gray(frames: list[Image.Image]) -> list[np.ndarray]:
+ """帧序列 → 定尺灰度矩阵列表(float32)。"""
+ return [np.asarray(f.convert("L").resize((SMALL, SMALL)), dtype=np.float32) for f in frames]
+
+
+# 分区动量用的尺度。比 SMALL 大,因为它要在**主体包围盒内**再切 3×2 个区 ——
+# 48×48 切完每区只剩 16×24,一条手臂占不到几个像素,量出来的差异全是量化噪声。
+MASK = 128
+
+
+def alpha_stack(frames: list[Image.Image]) -> np.ndarray:
+ """帧序列 → ``(n, MASK, MASK)`` 的主体掩码栈(bool)。
+
+ 用 alpha 而不是灰度:这些帧是抠过图的 RGBA,alpha 就是主体轮廓,而灰度会把
+ 深色衣服和透明背景混为一谈。没有 alpha 时退化成"非纯黑即主体"。
+ """
+ out = []
+ for fr in frames:
+ im = fr.resize((MASK, MASK))
+ if im.mode == "RGBA":
+ out.append(np.asarray(im.getchannel("A")) > 128)
+ else:
+ out.append(np.asarray(im.convert("L")) > 8)
+ return np.stack(out)
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py
new file mode 100644
index 00000000..10665f8b
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py
@@ -0,0 +1,107 @@
+"""视频抽帧(切片层的解码入口)。
+
+承接视频路线(Issue #35):i2v 产出的短视频步态真实但为插画质感。本模块只负责
+把视频 bytes 解码成帧序列;选帧(周期 / 一次性)见 :mod:`.loop` / :mod:`.oneshot`,
+像素化 / 对齐 / 打包见 :mod:`..postprocess`。抽帧后端(imageio/ffmpeg)函数内惰性,
+模块导入零成本、CI 可收集。
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import tempfile
+from pathlib import Path
+
+from PIL import Image
+
+logger = logging.getLogger("windup.ai_engine.extract")
+
+__all__ = ["extract_frames_bytes", "extract_all_frames_bytes"]
+
+
+def extract_frames_bytes(video: bytes, n: int) -> list[Image.Image]:
+ """从视频 bytes 均匀抽 ``n`` 帧(供后端 strategy 用,provider 返回的是 bytes)。"""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "source.mp4"
+ path.write_bytes(video)
+ return _extract_frames(str(path), n)
+
+
+def extract_all_frames_bytes(video: bytes, cap: int = 150) -> list[Image.Image]:
+ """抽视频全部帧(至多 ``cap``,均匀降采样),供周期检测用。"""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "source.mp4"
+ path.write_bytes(video)
+ return _extract_frames(str(path), cap)
+
+
+def _uniform_indices(total: int, n: int) -> list[int]:
+ """在 ``total`` 帧里均匀取 ``min(n, total)`` 个下标(含首尾)。"""
+ m = min(n, total)
+ return [round(i * (total - 1) / max(1, m - 1)) for i in range(m)]
+
+
+def _frame_count(video_path: str) -> int:
+ """帧数。先问容器元数据,不可信时退回逐帧计数(计数不保留帧,内存不涨)。
+
+ 元数据在 14 段真实 i2v 视频上与实际帧数全部一致(2026-08-10 实测),但不同容器/编码
+ 的 ``n_frames`` 并非都可靠,所以拿不到正整数就退回计数——多解一遍换一个确定的数,
+ 比按错的帧数抽出错位的帧划算。
+ """
+ import imageio.v3 as iio
+
+ try:
+ shape = iio.improps(video_path, plugin="pyav").shape
+ if shape and isinstance(shape[0], int) and shape[0] > 0:
+ return shape[0]
+ except Exception:
+ pass
+ return sum(1 for _ in iio.imiter(video_path, plugin="pyav"))
+
+
+def _extract_frames(video_path: str, n: int) -> list[Image.Image]:
+ """从视频均匀抽 ``n`` 帧。优先 imageio(流式),回退系统 ffmpeg。
+
+ **流式而不是一次性读整段**(2026-08-10,机器审 P2):原先走 ``iio.imread`` 会把
+ ``(T, H, W, C)`` 整个 materialize 出来。实测 121 帧 720p 的真实 i2v 视频峰值
+ 319 MiB,而我们只要其中 8~16 帧;并发 worker 叠加时这是实打实的内存墙。
+ 现在峰值≈保留帧数 × 单帧,与视频长度无关。
+ """
+ try:
+ import imageio.v3 as iio
+
+ total = _frame_count(video_path)
+ if total <= 0:
+ raise RuntimeError("视频无可解码帧")
+ wanted = set(_uniform_indices(total, n))
+ out: list[Image.Image] = []
+ for i, frame in enumerate(iio.imiter(video_path, plugin="pyav")):
+ if i in wanted:
+ # convert 之后原始 ndarray 就可以被回收;不持有 frame 本身。
+ out.append(Image.fromarray(frame).convert("RGBA"))
+ if len(out) == len(wanted):
+ break
+ if out:
+ return out
+ except Exception: # noqa: BLE001 - 兜底到 ffmpeg
+ # 不静默:这个 except 曾把"我们自己算错下标"和"环境里没装 imageio"混为一谈,
+ # 两者都表现为悄悄换用 ffmpeg 分支、产出看着正常的帧。至少留一条日志。
+ logger.warning("imageio 抽帧失败,回退系统 ffmpeg", exc_info=True)
+
+ import glob
+ import subprocess
+ from imageio_ffmpeg import get_ffmpeg_exe
+
+ with tempfile.TemporaryDirectory() as tmp:
+ subprocess.run(
+ [get_ffmpeg_exe(), "-y", "-i", video_path, "-vsync", "0",
+ os.path.join(tmp, "f_%04d.png")],
+ capture_output=True, check=True,
+ )
+ files = sorted(glob.glob(os.path.join(tmp, "f_*.png")))
+ if not files:
+ raise RuntimeError("抽帧失败:视频无可解码帧")
+ m = min(n, len(files))
+ idx = [round(i * (len(files) - 1) / max(1, m - 1)) for i in range(m)]
+ return [Image.open(files[i]).convert("RGBA").copy() for i in idx]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py
new file mode 100644
index 00000000..26a5b586
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py
@@ -0,0 +1,174 @@
+"""循环闭合(最后一公里之一,Issue #21)—— 从 i2v 密集帧里抽正好一个步态周期,做无缝 loop。
+
+i2v 的 5s 视频里含 ~2-3 个步态周期,均匀抽 N 帧跨多个周期 → 首尾接缝跳。做法:
+帧自相似检测周期(灰度小图,frame[i] 与 frame[i+p] 差最小的 p = 一个周期),
+再在一个周期内均匀取 N 帧 → frame[N-1] 的下一拍≈frame[0],循环自然闭合。
+纯 numpy / PIL,零 API。
+"""
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+from ._frames import SMALL as _SMALL
+from ._frames import gray as _gray
+
+__all__ = ["find_period", "pick_cycle"]
+
+
+def _deskew(gs: list[np.ndarray]) -> list[np.ndarray]:
+ """消掉角色的整体水平平移再比对。i2v 里角色会横着挪(实测走位达画宽 18%),而下游
+ :func:`postprocess.pack.align_bottom_center` 本来就会逐帧重新居中 —— 不消平移的话,
+ d(p) 被"挪了多远"主导、随 p 单调上升,真周期的凹陷被压平(骷髅走路真周期 56 完全消失,
+ 只剩 22 的假凹陷)。做法:中值背景差取主体列范围,把质心 roll 到画面中心。"""
+ a = np.stack(gs)
+ d = np.abs(a - np.median(a, axis=0))
+ thr = max(4.0, float(np.percentile(d, 99)) * 0.25)
+ out = []
+ for g, di in zip(gs, d):
+ cols = (di > thr).any(0)
+ cx = float(np.where(cols)[0].mean()) if cols.any() else _SMALL / 2
+ out.append(np.roll(g, int(round(_SMALL / 2 - cx)), axis=1))
+ return out
+
+
+def _dmat(gs: list[np.ndarray]) -> np.ndarray:
+ """全帧对距离矩阵(48x48 灰度平均绝对差),后续所有判据共用,只算一次。"""
+ flat = np.stack(gs).reshape(len(gs), -1)
+ return np.stack([np.abs(flat - flat[i]).mean(1) for i in range(len(gs))])
+
+
+def _curve(M: np.ndarray, pmin: int, pmax: int) -> dict[int, float]:
+ n = len(M)
+ return {p: float(np.mean([M[i, i + p] for i in range(n - p)])) for p in range(pmin, pmax + 1)}
+
+
+def _prominent_period(curve: dict[int, float], scale: float) -> tuple[int, float] | None:
+ """只认"内部局部极小 + 凹陷够深"的 p。曲线单调(无周期)时返回 None,而不是交出边界值。"""
+ ps = sorted(curve)
+ best = None
+ for j in range(1, len(ps) - 1):
+ p = ps[j]
+ if not (curve[p] <= curve[ps[j - 1]] and curve[p] <= curve[ps[j + 1]]):
+ continue
+ w = max(3, p // 2)
+ lo = [curve[q] for q in ps if p - w <= q < p]
+ hi = [curve[q] for q in ps if p < q <= p + w]
+ if not lo or not hi:
+ continue
+ prom = (min(max(lo), max(hi)) - curve[p]) / max(scale, 1e-6)
+ if best is None or prom > best[1]:
+ best = (p, prom)
+ return best
+
+
+def find_period(frames: list[Image.Image], pmin: int | None = None, pmax: int | None = None) -> int:
+ """自相似求步态周期(帧数)。frame[i] 与 frame[i+p] 平均差最小的 p。"""
+ n = len(frames)
+ gs = _gray(frames)
+ pmin = pmin or max(4, n // 6)
+ pmax = pmax or max(pmin + 1, n // 2)
+ best_p, best_d = pmin, float("inf")
+ for p in range(pmin, pmax + 1):
+ d = float(np.mean([np.abs(gs[i] - gs[i + p]).mean() for i in range(n - p)]))
+ if d < best_d:
+ best_d, best_p = d, p
+ return best_p
+
+
+def _offsets(P: int, n: int) -> list[int]:
+ return [round(k * P / n) for k in range(n)]
+
+
+def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]:
+ """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。返回长度恒等于 ``n``。
+
+ 周期检测的三个坑(实测 5 段真 i2v 视频):
+ 1. d(p) 会被"角色整体平移 + 画质漂移"抬成单调上升 —— 直接取 argmin 会滑到搜索窗边界
+ 交出**假周期**(走路视频: p 恒等于 pmin)。故只认有足够凹陷深度的内部局部极小,
+ 测不到就判"无周期",退化成全片均匀取(不硬闭环) —— 硬闭环反而制造接缝。
+ 2. 搜索窗要覆盖真周期: 上界 n//2 会把 5s 里只有 ~2 个周期的待机挡在窗外(实测真周期 62
+ > pmax 60,于是取到边界值);下界要 >= 目标帧数 n,否则 round(k*p/n) 直接产出重复帧。
+ 3. 谐波: 平移偏置让 d(p) 偏爱短 lag,常选中真周期的 1/2(骷髅走路: 取到 22,真周期 ~56),
+ 半周期闭环 = 末帧接回首帧时左右腿瞬间互换 = 肉眼可见的"跳一下"。故在 p 的整数倍里
+ 按**归一化接缝**(末→首 差 / 组内相邻差均值)复选,并保证取样索引互不重复。
+ 不要在这里接 :mod:`.quality` 的死帧判据 —— 2026-08-07 消融实测(4 段真 i2v)证否:
+ ① 用 ``active_span`` 先掐头尾冻结段:``_deskew`` 已经解决了"曲线被不动的帧压平"这个
+ 问题,再掐只是缩小 i0 的搜索空间、丢掉更优相位起点(奔跑 seam 0.81→2.00);
+ ② 取样后按 ``dead_frame_mask`` 就近避让死帧:i2v 死帧占比常达一半(24fps 容器隔帧复制,
+ 实测 59/121 与 63/121),避让会系统性打乱相位均匀性,反而选中更多死帧(1→3)。
+ 候选评分里的 ``a < 0.5 * scale`` 已经排掉"几乎不动"的窗口,够用。
+ quality 不进选帧(它另有一份出参职责,见 :mod:`.quality`)。
+
+ **已知缺口(2026-08-09 记,未修)**:本函数有四条返回路径,其中三条是 return 帧列表,
+ 调用方**分不清走了哪条**:
+ 1. ``total <= n`` 原样返回(源帧比要的还少,根本没选);
+ 2. 测不到可信周期 → 全片均匀取(**降级**,不闭环);
+ 3. 候选全被否 → 全片均匀取(**降级**,同上);
+ 4. 正常闭环。
+ 2 与 3 的补救方式不同(2 多半是母版/动作幅度问题,该换母版;3 多半是视频里周期数
+ 不够,该加长视频),但今天都表现为"一组看起来正常的帧"。**降级对交付物的后果**是可测
+ 的 —— ``ports.ActionQuality.loop_seam`` 在交付帧上量归一化接缝,降级通常表现为接缝
+ 偏大;但**降级的原因**测不出来。没有顺手把状态塞进返回值,是因为那要改本函数的返回
+ 形状、波及所有调用方,而今天还没有任何调用方会依据"原因"改变行为。等真有调用方要按
+ 原因给不同提示时,再让本函数返回 ``(frames, reason)``。
+ """
+ # n<=0 没有合法语义(要 0 帧的动画不存在),且两条出路都是坏的(2026-08-10 实测):
+ # 检出周期时 `_offsets(P, 0)` 交出空 offsets,一路走到 `M[idx[-1], idx[0]]` 抛 IndexError;
+ # 测不到周期时(单调曲线)直接静默返回 [] —— 后者更危险,故在入口显式拒绝。
+ # (机器审说这里除零并不准确:`range(n)` 为空,`k*P/n` 根本没被求值。)
+ if n <= 0:
+ raise ValueError(f"n 必须 >= 1,收到 {n}")
+ total = len(frames)
+ if total < n:
+ # 源帧不够就报错,不再原样返回:长度不足且不报错,下游 frame_durations 按实际长度现算,
+ # 帧数与时长表自洽,server 看不出异常,用户拿到的是一段没走完的循环。
+ raise ValueError(f"源帧不足:请求 {n} 帧,只有 {total} 帧")
+ if total == n:
+ return frames
+ M = _dmat(_deskew(_gray(frames)))
+ if n == 1:
+ # 单帧"循环"没有接缝也没有相位,下面整套周期/接缝机制全部失效(实测 n=1 时窗口内相邻差
+ # 是空均值 = nan,`a < 0.5*scale` 与接缝评分双双被 nan 短路,靠比较运算的意外结果才
+ # 返回 frames[0])。显式取 medoid:与全片平均姿态最近的一帧 = 循环停留最久的相位,
+ # 比 frames[0](i2v 的首帧是母版静立姿,单看读不出"在走")更能代表这个循环。
+ return [frames[int(np.argmin(M.mean(1)))]]
+ adj = np.array([M[i, i + 1] for i in range(total - 1)])
+ scale = float(np.median(adj))
+
+ pmin = max(6, min(n, total // 6))
+ pmax = min(total - 3, max(pmin + 2, int(total * 0.6)))
+ got = _prominent_period(_curve(M, pmin, pmax), scale)
+ if got is None or got[1] < 0.25: # 测不到可信周期 → 不硬闭环
+ idx = [round(k * (total - 1) / n) for k in range(n)]
+ return [frames[i] for i in idx]
+
+ p = got[0]
+ cands = []
+ for k in range(1, 4): # 在基周期的整数倍里复选
+ P = k * p
+ if P > total - 2:
+ break
+ offs = _offsets(P, n)
+ if len(set(offs)) < n: # 该倍数取不出 n 个不同相位
+ continue
+ best = None
+ for i0 in range(total - P):
+ idx = [i0 + o for o in offs]
+ a = float(np.mean([M[idx[j], idx[j + 1]] for j in range(n - 1)]))
+ if a < 0.5 * scale: # 窗口几乎不动(i2v 尾部常停顿)→ 弃
+ continue
+ score = M[idx[-1], idx[0]] / max(a, 1e-6) # 归一化接缝
+ if best is None or score < best[0]:
+ best = (score, idx)
+ if best:
+ cands.append((k, best[0], best[1]))
+ if not cands:
+ idx = [round(k * (total - 1) / n) for k in range(n)]
+ return [frames[i] for i in idx]
+ # 倍数越大 = 一个 loop 里塞进越多周期 = 每周期帧数越少(动作变糙),故**优先最小倍数**:
+ # 取第一个"已经闭合"的倍数(归一化接缝 <= 1.2,即末→首的跳幅不超过一个正常帧间步长),
+ # 都不闭合才退而取最优 —— 这一条专治"取到半周期 → 接缝处左右腿瞬间互换"。
+ ok = [c for c in cands if c[1] <= 1.2]
+ pick = ok[0] if ok else min(cands, key=lambda c: c[1])
+ return [frames[i] for i in pick[2]]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py
new file mode 100644
index 00000000..dcbf1507
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py
@@ -0,0 +1,246 @@
+"""一次性动作(jump / attack / hit)的抽帧:裁动作起止 + 按状态切段。
+
+与循环类(idle/walk/run)的根本差别:
+- 循环类用 :mod:`.loop` 找步态周期抽单周期闭环;一次性动作**不能闭环** —— 首尾姿态不同,
+ 强行闭环会把落地帧接回蓄力帧,读起来是抽搐。
+- i2v 出的 5s 视频里,真正的动作往往只占中间一段(前后是静止的起手/终态保持),直接均匀
+ 抽帧会浪费一半帧在不动的地方 → 需要先**裁到动作发生的区间**。
+- jump 还要进一步**按状态切段**(蓄力/上升/顶点/下降/落地),因为引擎里悬空时长由物理
+ 决定、上升中可被打断,必须能分段播放。
+
+纯 numpy / PIL,零 API。
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image
+
+from windup_ai_engine._subject import subject_mask as _subject_mask
+
+__all__ = [
+ "find_motion_span",
+ "first_action_end",
+ "pick_oneshot",
+ "split_jump_phases",
+ "foot_line_series",
+]
+
+
+_KINDS = ("swing", "airborne")
+
+
+def _check_kind(kind: str) -> None:
+ """未知 ``kind`` 直接报错,不静默回落到 ``swing``。
+
+ 两个判据的物理不同(脚线回地 vs 能量跌破),拼错一个字母(``"airbourne"``)若被当成
+ ``swing`` 处理,跳跃会按能量判据裁 —— 出的是"看起来成功"的错区间(实测:顶点悬停处
+ 能量安静,动作被截在半空),而这类错误在序列帧里很难回溯到 kind 拼错上。
+ """
+ if kind not in _KINDS:
+ raise ValueError(f"kind 只能是 'swing' 或 'airborne',收到 {kind!r}")
+
+
+def _frame_energy(frames: list[Image.Image], size: int = 64) -> np.ndarray:
+ """逐帧与前一帧的差异强度(灰度小图),长度 = len(frames)-1。"""
+ gs = [np.asarray(f.convert("L").resize((size, size)), dtype=np.float32) for f in frames]
+ return np.array([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)])
+
+
+def find_motion_span(frames: list[Image.Image], rel_thr: float = 0.25) -> tuple[int, int]:
+ """定位"动作真正发生"的帧区间 ``[start, end]``(含端点)。
+
+ 以帧间差异强度超过峰值 ``rel_thr`` 倍的最早/最晚位置为界,并各留一帧余量。
+ 静止的起手与终态保持会被裁掉。
+ """
+ if len(frames) < 3:
+ return 0, len(frames) - 1
+ e = _frame_energy(frames)
+ peak = float(e.max())
+ if peak <= 1e-6:
+ return 0, len(frames) - 1
+ active = np.flatnonzero(e >= peak * rel_thr)
+ if not len(active):
+ return 0, len(frames) - 1
+ start = max(0, int(active[0]) - 1)
+ end = min(len(frames) - 1, int(active[-1]) + 2)
+ return start, end
+
+
+def _airborne_end(frames: list[Image.Image], start: int, end: int, tol: float = 6.0) -> int:
+ """腾空类(jump)的结束:脚线越过最高点后**首次回到地面**。
+
+ 几何信号,明确无歧义 —— 比任何"能量安静"判据都稳。
+ """
+ y = foot_line_series(frames[start : end + 1])
+ if len(y) < 4:
+ return end
+ apex = int(np.argmin(y))
+ ground = float(np.median([y[0], y[-1]]))
+ back = np.flatnonzero(y[apex:] >= ground - tol)
+ return min(end, start + apex + int(back[0]) + 2) if len(back) else end
+
+
+def _swing_end(frames: list[Image.Image], start: int, end: int,
+ drop_ratio: float = 0.35, recover: int = 2) -> int:
+ """挥击类(attack/hit)的结束:能量越过峰值后**首次跌到峰值的 ``drop_ratio``**,再留收势余量。
+
+ 挥击是"蓄力 → 峰值 → 收势"的单峰结构,收势很短,故用"跌破比例 + 固定余量"即可;
+ 不要求长时间静止 —— 实测挥砍收势段的能量并不干净(视频压缩噪点),等不到静止平台。
+ """
+ e = _frame_energy(frames[start : end + 1])
+ if len(e) < 4:
+ return end
+ peak_i = int(np.argmax(e))
+ thr = float(e.max()) * drop_ratio
+ for i in range(peak_i + 1, len(e)):
+ if e[i] < thr:
+ return min(end, start + i + recover)
+ return end
+
+
+def first_action_end(
+ frames: list[Image.Image], start: int, end: int, kind: str = "swing"
+) -> int:
+ """在 ``[start, end]`` 内找**第一次**动作的结束帧,按动作物理分流。
+
+ i2v 常在 5s 里把一次性动作**复读第二遍**(实测:提示词写了 "ONCE",兽人跳了两次、
+ 挥砍也挥了两次),不裁会把两次动作压进一套序列帧。
+
+ 不同动作的"结束"信号本质不同,**一个通用判据管不了两种**(实测踩过):
+ - ``kind="airborne"``(jump):脚线回到地面 —— 几何、无歧义。
+ - ``kind="swing"``(attack/hit):能量跌破峰值比例 + 收势余量。
+
+ 三个已验证无效的通用解法(别再试):①只看"帧间安静" → 在跳跃**顶点悬停**处误触发,
+ 把动作截在半空;②要求静止段足够长 → 挥砍收势并不干净(压缩噪点),等不到,完全不裁;
+ ③找"回到起始姿态"的谷底 → 收势姿态(戒备)与起始姿态(蓄力)不同,回不到低位。
+ """
+ _check_kind(kind) # 放在早返回之前:短区间也不能放过拼错的 kind
+ if end - start < 4:
+ return end
+ return (_airborne_end if kind == "airborne" else _swing_end)(frames, start, end)
+
+
+def _key_pose(span: list[Image.Image], kind: str) -> int:
+ """区间内最能代表这次动作的单帧下标(关键姿势)。
+
+ 只取一帧时不能取区间首帧或中点:
+ - 首帧是蓄力起手,和待机几乎一个样,单看认不出这是攻击还是跳跃;末帧是收势/落地,同理。
+ - 中点也不行:动作区间前后不对称(蓄力长、收势短 —— :func:`_swing_end` 只留 2 帧余量),
+ 中点会落进蓄力段。
+ 故取"关键姿势":判据与 :func:`first_action_end` 同源,不引入新参数 ——
+ ``airborne`` 取脚线最高(顶点),``swing`` 取能量峰。能量是**帧间**差(长度 len-1),
+ 峰值下标 i 表示 i→i+1 这一跳变化最大,故取 i+1 = 刚完成最快一段位移的那一帧(命中瞬间)。
+ """
+ if len(span) < 2:
+ # 单帧区间:答案唯一(就那一帧),没有歧义,不必炸。当前 pick_oneshot 恒给 >= 2 帧
+ # (end 至少 start+1),但那是上游三个启发式判据合出来的保证、不是本函数签名的保证,
+ # 故留这一行 —— 否则 _frame_energy 会交出空数组、argmax 抛一个看不懂的 numpy 错。
+ return 0
+ if kind == "airborne":
+ return int(np.argmin(foot_line_series(span)))
+ return min(len(span) - 1, int(np.argmax(_frame_energy(span))) + 1)
+
+
+def _widen_span(start: int, end: int, n: int, total: int) -> tuple[int, int]:
+ """区间不足 n 帧时把窗口放宽回来,保证能取出 n 个**互不相同**的源帧。
+
+ 调用处已保证 ``total > n``,即源帧数是够的 —— 区间短只是我们自己的裁剪判据收得紧
+ (2026-08-10 实测:14 帧输入裁到 9 帧区间,请求 12 帧只回 9 帧)。此时既不该报错(源帧够),
+ 更不该静默少给帧:下游 ``frame_durations(action, len(frames))`` 按实际长度现算时长,帧数与
+ 时长表自洽,server 看不出异常,用户拿到的是一段步子没走完的动作。故按缺口对称放宽,
+ 宁可带上几帧起手/收势的静止帧,也要给足请求的帧数。
+
+ 动作贴在视频尾部时右边长不动,缺口必须退回左边补(最后一行),否则窗口仍不足 n 帧、
+ 只能靠重复帧凑数 —— 长度对、内容卡顿,又是一种"看起来成功"。
+ """
+ deficit = n - (end - start + 1)
+ if deficit <= 0:
+ return start, end
+ left = min(start, (deficit + 1) // 2) # 先往左补一半,左边不够就全从右边补
+ start -= left
+ end = min(total - 1, end + deficit - left)
+ return max(0, end - n + 1), end # 右边撞到尾部时把缺口退回左边
+
+
+def pick_oneshot(
+ frames: list[Image.Image], n: int, first_only: bool = True, kind: str = "swing"
+) -> list[Image.Image]:
+ """一次性动作抽 ``n`` 帧:裁到动作区间 → 只留第一次动作 → 区间内均匀取(不闭环)。
+
+ ``first_only`` 默认开:防 i2v 在 5s 内复读第二遍动作被一起抽进来。
+ ``kind``:``"airborne"``(jump,按脚线回地判结束)或 ``"swing"``(attack/hit,按能量跌破判)。
+
+ 返回长度**恒等于** ``n``;源帧不够 n 帧则报错,不静默少给。
+ """
+ _check_kind(kind) # first_only=False 时不走 first_action_end,这里兜住
+ if n <= 0:
+ # 2026-08-10 实测:n<=0 原本静默返回 [](range(n) 为空,连除零都不报),"成功"地交出零帧。
+ raise ValueError(f"n 必须 >= 1,收到 {n}")
+ if len(frames) < n:
+ raise ValueError(f"源帧不足:请求 {n} 帧,只有 {len(frames)} 帧")
+ if len(frames) == n:
+ return frames
+ start, end = find_motion_span(frames)
+ if first_only:
+ end = max(start + 1, first_action_end(frames, start, end, kind=kind))
+ start, end = _widen_span(start, end, n, len(frames))
+ span = frames[start : end + 1]
+ if n == 1: # n=1 撞下面的 /(n-1) 除零(机器审 P1,2026-08-10 复现)
+ return [span[_key_pose(span, kind)]]
+ idx = [round(i * (len(span) - 1) / (n - 1)) for i in range(n)]
+ return [span[i] for i in idx]
+
+
+def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray:
+ """主体所在的行下标。判据本身在 :mod:`.._subject`(与母版预检共用同一个主体定义)。"""
+ return np.where(_subject_mask(frame, alpha_thr, bg_tol))[0]
+
+
+def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray:
+ """逐帧主体**底边** y 坐标(脚线)。跳跃时脚线先降(蹲)、再升(腾空)、再落回。"""
+ out = []
+ for f in frames:
+ ys = _subject_rows(f, alpha_thr)
+ out.append(float(ys.max()) if len(ys) else np.nan)
+ arr = np.array(out, dtype=np.float32)
+ if np.isnan(arr).any(): # 空帧用邻近值补
+ idx = np.arange(len(arr))
+ good = ~np.isnan(arr)
+ if good.any():
+ arr = np.interp(idx, idx[good], arr[good])
+ else:
+ arr = np.zeros_like(arr)
+ return arr
+
+
+def split_jump_phases(frames: list[Image.Image]) -> dict[str, list[int]]:
+ """按脚线轨迹把跳跃切成 crouch / rise / apex / fall / land 五段,返回每段的帧下标。
+
+ 判据:脚线 y 越小 = 人越高。最高点(y 最小)即 apex;起跳前脚线最低(蹲)处为 crouch
+ 结束;之后到 apex 为 rise,apex 之后到脚线回到地面高度为 fall,余下为 land。
+ 只依赖几何,不依赖模型。
+ """
+ n = len(frames)
+ if n < 5:
+ return {"rise": list(range(n))}
+ y = foot_line_series(frames)
+ apex = int(np.argmin(y)) # 最高点
+ ground = float(np.median([y[0], y[-1]])) # 地面脚线
+ # 起跳点:apex 之前脚线最低(数值最大 = 蹲得最深)的位置
+ takeoff = int(np.argmax(y[: max(1, apex)])) if apex > 0 else 0
+ # 落地点:apex 之后脚线首次回到地面附近
+ after = y[apex:]
+ back = np.flatnonzero(after >= ground - 2)
+ landing = apex + int(back[0]) if len(back) else n - 1
+
+ apex_lo = max(takeoff + 1, apex - 1)
+ apex_hi = min(landing - 1, apex + 1)
+ phases = {
+ "crouch": list(range(0, takeoff + 1)),
+ "rise": list(range(takeoff + 1, apex_lo)),
+ "apex": list(range(apex_lo, apex_hi + 1)),
+ "fall": list(range(apex_hi + 1, landing)),
+ "land": list(range(landing, n)),
+ }
+ return {k: v for k, v in phases.items() if v}
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py
new file mode 100644
index 00000000..92d0d311
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py
@@ -0,0 +1,192 @@
+"""帧质量诊断:死帧(重复/冻结)与坏帧(糊/伪影)的判据。
+
+与 :mod:`.loop` 的分工:loop 负责选帧,本模块只负责"这帧是什么成色"。
+2026-08-05 实测(6 个真 i2v 视频):**没有一帧糊帧**,但死帧极多——24fps 容器里
+隔一帧就是复制帧(奔跑视频奇偶帧差比 22.5x),有效内容帧率只有 ~11-14fps;
+且普遍有起步冻结(头部)或动作衰减停住(尾部)。所以"坏帧"与"死帧"必须分开判、
+分开统计——只判一型会漏掉一半。
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from ._frames import gray as _gray
+
+__all__ = ["active_span", "blur_ratio", "dead_frame_indices", "dead_frame_mask",
+ "frame_deltas", "limb_motion", "loop_seam", "motion_scale"]
+
+
+def frame_deltas(frames) -> np.ndarray:
+ """d[i] = |f_i - f_{i-1}| 均值,d[0]=0。小图(48x48 灰度),CPU 便宜。"""
+ gs = _gray(frames)
+ return np.array([0.0] + [float(np.abs(gs[i] - gs[i - 1]).mean()) for i in range(1, len(gs))])
+
+
+def dead_frame_mask(frames, ratio: float = 0.35, floor: float = 0.25) -> np.ndarray:
+ """死帧 = 相对前一帧几乎没有新内容。两型必须都判,缺一漏一半:
+
+ A 型「隔帧死」: d[i] < ratio * max(d[i-1], d[i+1])
+ i2v 常见"有效帧率减半"——24fps 容器里隔一帧就是复制帧。只用全局阈值抓不到,
+ 因为半数帧是死帧时 median 本身落在死帧堆里(实测 run 奇偶比 9.9x 却报 0 死帧)。
+ B 型「持续冻结」: d[i] < floor * p75(d)
+ 视频头部的 i2v 起步冻结、尾部的动作衰减停住。只用 A 型抓不到,
+ 因为连续冻结段里邻居同样低,比值≈1(实测 attack 尾部 9 帧全漏)。
+ """
+ d = frame_deltas(frames) if not isinstance(frames, np.ndarray) else frames
+ n = len(d)
+ p75 = float(np.percentile(d[1:], 75)) if n > 1 else 0.0
+ m = np.zeros(n, dtype=bool)
+ for i in range(1, n):
+ nb = [d[j] for j in (i - 1, i + 1) if 1 <= j < n]
+ if nb and d[i] < ratio * max(nb):
+ m[i] = True
+ if d[i] < floor * p75:
+ m[i] = True
+ return m
+
+
+def dead_frame_indices(frames) -> tuple[int, ...]:
+ """死帧下标。:func:`dead_frame_mask` 的出参形态转换 —— 掩码是算的时候好用的形态,
+ 跨出 ai_engine 的契约(``ports.ActionQuality``)要的是"哪几帧",不该让调用方拿着
+ 一个 numpy 掩码去自己 argwhere。"""
+ return tuple(int(i) for i in np.flatnonzero(dead_frame_mask(frames)))
+
+
+def motion_scale(frames) -> float:
+ """相邻帧平均差异的**绝对**尺度(48×48 灰度)。0.0 = 这些帧逐像素完全一样。
+
+ 为什么与 :func:`dead_frame_mask` 并存、而不是从它推导:后者两条判据
+ (``d[i] < ratio*max(邻居)`` 与 ``d[i] < floor*p75``)**都是相对的**,整段完全
+ 冻结时 d 全为 0,两条不等式变成 ``0 < 0``,一条都不成立 —— **一帧死帧都报不出**
+ (2026-08-09 用全同帧序列实测:12 帧全同,死帧数 0)。相对判据天生看不见"整体
+ 没动",绝对尺度必须单独给一个。
+ """
+ d = frame_deltas(frames)
+ return float(d[1:].mean()) if len(d) > 1 else 0.0
+
+
+def loop_seam(frames) -> float | None:
+ """末帧接回首帧的跳幅 ÷ 相邻帧平均步长;整段静止(分母为 0)返回 ``None``。
+
+ 与 :func:`.loop.pick_cycle` 选帧时的归一化接缝同式,但**测的对象不同**:pick_cycle
+ 在抠图 / 像素化 / 脚线对齐**之前**的密集帧上打分,而用户看到的是这三步之后的帧,
+ 这三步都会改动像素。要描述交付物就得在交付物上量。
+
+ 不套 :func:`.loop._deskew`:交付帧已被 ``align_bottom_center`` 逐帧居中,整体平移
+ 早消掉了,再按差分质心对一次只是引入第二套居中口径(两套口径不一致正是本仓反复
+ 踩的那类静默分歧)。
+
+ 分母为 0 时返回 None 而不是 0.0 —— 0.0 会被读成"完美闭环",而真相是"没有可比的
+ 步长,这个数不可读"。
+ """
+ gs = _gray(frames)
+ if len(gs) < 2:
+ return None
+ step = float(np.mean([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)]))
+ if step <= 0.0:
+ return None
+ return float(np.abs(gs[-1] - gs[0]).mean() / step)
+
+
+def active_span(frames, floor: float = 0.25, min_run: int = 3) -> tuple[int, int]:
+ """掐掉头尾的**持续**冻结段,返回 [s, e](闭区间)。中间的隔帧死不动。"""
+ d = frame_deltas(frames)
+ n = len(d)
+ p75 = float(np.percentile(d[1:], 75)) if n > 1 else 0.0
+ low = d < floor * p75
+ s, e = 0, n - 1
+ r = 0
+ for i in range(1, n): # 头部
+ if low[i]:
+ r += 1
+ else:
+ break
+ if r >= min_run:
+ s = r
+ r = 0
+ for i in range(n - 1, 0, -1): # 尾部
+ if low[i]:
+ r += 1
+ else:
+ break
+ if r >= min_run:
+ e = n - 1 - r
+ if e - s < 4: # 掐过头就放弃
+ return 0, n - 1
+ return s, e
+
+
+def blur_ratio(frames, ps: int = 32) -> np.ndarray:
+ """逐帧「静止区清晰度 / 前后帧同区清晰度」。<1 = 这帧自己糊了,与动作快慢无关。"""
+ def _pm(a):
+ h, w = a.shape
+ H, W = max(ps, h // ps * ps), max(ps, w // ps * ps)
+ a = a[:H, :W]
+ return a.reshape(H // ps, ps, W // ps, ps).mean(axis=(1, 3))
+
+ def _ag(g):
+ gx = np.zeros_like(g)
+ gy = np.zeros_like(g)
+ gx[:, 1:-1] = np.abs(g[:, 2:] - g[:, :-2]) * .5
+ gy[1:-1, :] = np.abs(g[2:, :] - g[:-2, :]) * .5
+ return np.maximum(gx, gy)
+
+ gs = [np.asarray(f.convert("L"), np.float32) for f in frames]
+ sharp = np.stack([_pm(_ag(g)) for g in gs])
+ out = np.ones(len(gs), np.float32)
+ for i in range(1, len(gs) - 1):
+ mv = np.maximum(_pm(np.abs(gs[i] - gs[i - 1])), _pm(np.abs(gs[i] - gs[i + 1])))
+ ref = .5 * (sharp[i - 1] + sharp[i + 1])
+ m = (mv < 2.5) & (ref > 3.0)
+ if m.sum() < 4:
+ cand = ref > 3.0
+ if cand.sum() < 4:
+ continue
+ k = max(4, int(cand.sum() * .25))
+ m = cand & (mv <= np.sort(mv[cand])[:k].max())
+ out[i] = float(np.median(sharp[i][m] / np.maximum(ref[m], 1e-6)))
+ return out
+
+
+def limb_motion(frames, *, grid: tuple[int, int] = (3, 2)) -> dict[str, float]:
+ """按身体分区量动量,返回**各区占总动量的比例** + 最静区名 ``still``。
+
+ 存在的理由是整幅平均逮不到"一部分肢体在动、另一部分冻着"::func:`motion_scale`
+ 与 :func:`dead_frame_mask` 的判据都是相对的,整体在动时它们全绿,而"腿在迈、手臂
+ 僵成柱子"正是**自动绑骨漏认肢体**的典型表现 —— 那块网格没有骨骼驱动,每帧同姿势。
+
+ 只报占比、不给合格线:几何分区区分不了"该动没动"和"本来就不该动",那需要动作语义。
+ 试过的两个汇总判据都不成立 —— ``max/min`` 的分母常落在本就不该动的静区,
+ ``max/median`` 在半数区冻结时中位数落到活跃那侧,恰在最该报警时比值 ≈ 1.0。
+ 故**判决交给调用方**,与 :class:`ports.ActionQuality` 其余读数同一取向。
+
+ 读法:6 区均匀分布时各占 ≈0.17;某区接近 0 且按动作语义**该动**,那块多半没被骨骼
+ 驱动。分区按主体包围盒切(不是整幅画布),否则角色在画面里位置一变区就对不上。
+ """
+ from ._frames import alpha_stack as _masks
+
+ m = _masks(frames)
+ if m.shape[0] < 2:
+ return {"still": ""}
+ ys, xs = np.where(m.any(0))
+ if not len(ys):
+ return {"still": ""}
+ y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
+ rows, cols = grid
+ hs = np.linspace(y0, y1, rows + 1).astype(int)
+ ws = np.linspace(x0, x1, cols + 1).astype(int)
+ names_r = ("上", "中", "下")[:rows]
+ names_c = ("左", "右")[:cols] if cols == 2 else tuple(str(i) for i in range(cols))
+
+ raw: dict[str, float] = {}
+ for r in range(rows):
+ for c in range(cols):
+ blk = m[:, hs[r]:hs[r + 1], ws[c]:ws[c + 1]]
+ raw[f"{names_r[r]}{names_c[c]}"] = float(blk.std(0).sum())
+ total = sum(raw.values())
+ out: dict[str, float] = {k: round(v / total, 3) if total > 0 else 0.0
+ for k, v in raw.items()}
+ # 在**只有分区**的字典上挑最静区,再把它写回去。反过来的话 `still` 会挑中自己写进去
+ # 的汇总键 —— 一个只在数据上体现、不会报错的错。
+ out["still"] = min(raw, key=lambda k: raw[k])
+ return out
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep b/backend/packages/ai_engine/src/windup_ai_engine/strategy/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py
new file mode 100644
index 00000000..afb5865d
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py
@@ -0,0 +1,13 @@
+"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 各条 DerivationStrategy。"""
+
+from .base import CYCLIC_ACTIONS, ROUTE_MATRIX, DerivationStrategy
+from .concrete import PerFrameStrategy, RenderFrameStrategy, VideoFrameStrategy
+
+__all__ = [
+ "ROUTE_MATRIX",
+ "CYCLIC_ACTIONS",
+ "DerivationStrategy",
+ "VideoFrameStrategy",
+ "PerFrameStrategy",
+ "RenderFrameStrategy",
+]
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py
new file mode 100644
index 00000000..1865ad26
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py
@@ -0,0 +1,96 @@
+"""DerivationStrategy —— 按动作类型分流到生成路线(本营实测挣得的核心架构决策)。
+
+分流依据(有实测证据,非拍脑袋,详见关联 Issue #35 的工程文档):
+ - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞;
+ 必须走视频 i2v(视频模型天生连贯、腿自然交替)。
+ - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环
+ (见本模块 CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。
+ - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。
+ - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。
+
+ROUTE_MATRIX 是人主导的架构契约,改它=改产线,要有实测支撑。
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute
+
+from windup_ai_engine.ports import ProgressPort
+
+# 动作类型 → 生成路线(架构决策,写死为契约)
+ROUTE_MATRIX: dict[ActionType, GenRoute] = {
+ ActionType.WALK: GenRoute.VIDEO_I2V,
+ ActionType.RUN: GenRoute.VIDEO_I2V,
+ ActionType.JUMP: GenRoute.VIDEO_I2V,
+ ActionType.ATTACK: GenRoute.VIDEO_I2V,
+ # 自定义动作(#239):用户自述的动作绝大多数带位移或连续姿态变化,而逐帧独立生成锁不住
+ # 跨帧连续性 —— 与 walk 走视频是同一条理由。
+ ActionType.CUSTOM: GenRoute.VIDEO_I2V,
+ ActionType.HIT: GenRoute.PER_FRAME,
+ # idle 走 i2v(build_idle_prompt:躯干缓慢起伏呼吸)。
+ # **2026-08-07 定案**:#53 原设计的 ¥0 程序化 Idle-B(局部网格呼吸)放弃 —— 做不出
+ # 可用效果,idle 认这份 i2v 的钱。GenRoute.PROC_IDLE 与 ProcIdleStrategy 已一并移除。
+ ActionType.IDLE: GenRoute.VIDEO_I2V,
+}
+
+# 循环类动作:抽单步态周期闭环。一次性动作**不能闭环**(首尾姿态不同,强行闭环会把
+# 落地帧接回蓄力帧=抽搐),走"裁动作区间 + 区间内均匀取"。
+# 与 ROUTE_MATRIX 并排放在 base 而不是留在 concrete:它同样是「动作类型 → 产线行为」的
+# 契约,且现在有两个消费方 —— strategy.concrete 用它选抽帧方式,impl.CharacterGenerator
+# 用它决定交付成色里的 loop_seam 该不该测(不闭环的动作没有"接缝"可言)。放在 concrete
+# 会让 generator 为了问一句"这动作循环吗"去 import 一条具体路线的实现。
+CYCLIC_ACTIONS: frozenset[ActionType] = frozenset(
+ {ActionType.IDLE, ActionType.WALK, ActionType.RUN}
+)
+
+
+def is_cyclic(action: ActionSpec) -> bool:
+ """这次生成要不要按"单周期闭环"处理。
+
+ **不要直接判 `action.action in CYCLIC_ACTIONS`** —— 那张表只覆盖写死的那几个动作,
+ 对 ``CUSTOM`` 恒为 False,于是用户勾了"循环播放"的自定义动作会被当成一次性,
+ 抽帧不闭环、也不量 loop_seam。而这个错是静默的:帧数、时长、成色全部正常。
+
+ ``CUSTOM`` 的循环性由调用方在 ``ActionSpec.cyclic`` 显式声明(该字段对 custom
+ 必填,见其校验器),所以这里不需要兜底默认值。
+ """
+ if action.action is ActionType.CUSTOM:
+ return bool(action.cyclic)
+ return action.action in CYCLIC_ACTIONS
+
+# 本矩阵的形状本身有个已知边界,记录在此以免后来者按错误前提扩展:
+# 它是「动作类型 → 路线」的一对一映射,隐含前提是"路线由动作的物理性质唯一决定"。
+# 该前提对逐帧 / 视频两条路线成立(有无连续步态是动作固有属性),但对渲染出帧路线不成立
+# —— 同一个 walk 既可走 i2v 也可走渲染,选哪条取决于"该造型有没有 3D 模型",那是 server
+# 才知道的事。
+#
+# 所以渲染出帧路线**不进本矩阵**:server 读 DB 判断该造型有没有 3D 资产,再直接调
+# ``CharacterGeneratorPort.generate_rendered``。本矩阵只留"由动作物理性质唯一决定"的
+# 那两条,隐含前提得以保住。反过来把它改成「动作 → 可选路线集合」+ 选择器,等于把一个
+# 只有 DB 答得出的问题塞进引擎。Refs 1024XEngineer/Windup#81 #122。
+
+
+class DerivationStrategy(ABC):
+ """一条生成路线的骨架:一份源 bytes → 对齐前的角色帧序列。
+
+ 没有"我能不能服务这个角色"的自报钩子:那个判据在 DB 里,只有 server 看得到。
+ """
+
+ route: GenRoute
+
+ @abstractmethod
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ source: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ """从源 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。
+
+ ``source`` 随路线不同:i2v / 逐帧吃定妆母版图,三渲二吃已绑骨的 3D 模型 ——
+ 两者由各自的入口喂进来,不存在传错。各实现按自己吃的东西给形参命名,
+ 统一叫 ``master`` 会让渲染路线的签名说谎。
+ """
+ raise NotImplementedError
diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py
new file mode 100644
index 00000000..4933dcd9
--- /dev/null
+++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py
@@ -0,0 +1,292 @@
+"""三条 DerivationStrategy。
+
+- VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。
+- PerFrameStrategy:**未实现**,调用即抛 NotImplementedError(见 #53)。不返回空帧——
+ 空帧会伪装成一次成功的生成流到 server 落库,用户看到的是一组裂图。
+- RenderFrameStrategy:三渲二。吃**已绑骨的 3D 模型**(不是母版图),纯本地渲帧、
+ 零 API 成本;建模型那两段按次计费的活在 server 侧(#121 #122)。
+
+VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 →
+matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from windup_common.models import (
+ ActionSpec,
+ ActionType,
+ CharacterCard,
+ Facing,
+ GenRoute,
+ Stylize,
+)
+from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider
+
+from windup_ai_engine._imgio import from_png as _img
+from windup_ai_engine._imgio import to_png as _png
+from windup_ai_engine.master_prep import prepare_master
+from windup_ai_engine.ports import ProgressPort
+from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames
+from windup_ai_engine.slicing import extract_all_frames_bytes, pick_cycle, pick_oneshot
+from windup_ai_engine.prompt import (
+ build_attack_prompt,
+ build_custom_prompt,
+ build_idle_prompt,
+ build_jump_prompt,
+ build_walk_prompt,
+)
+from windup_ai_engine.strategy.base import DerivationStrategy, is_cyclic
+
+if TYPE_CHECKING:
+ from windup_framework.providers.render3d import SpriteRenderProvider, SpriteSheet
+
+
+class VideoFrameStrategy(DerivationStrategy):
+ """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。
+
+ 覆盖循环类(walk/run)与一次性类(jump/attack)——按 :data:`CYCLIC_ACTIONS` 分流抽帧方式。
+ 硬前提:**提示词朝向必须与母版一致**(side/front);给正面母版喂侧走词会让模型靠转身
+ 调和图文矛盾(实测 #35)。
+ """
+
+ route = GenRoute.VIDEO_I2V
+
+ def __init__(self, video: VideoProvider, matte: MatteProvider) -> None:
+ self._video = video
+ self._matte = matte
+
+ def _build_prompt(self, action: ActionSpec) -> str:
+ """按动作类型选提示词;朝向随 ActionSpec.facing。"""
+ # custom 单独一支:它的动作内容来自用户,只能由 build_custom_prompt 把那句话
+ # 嵌进机制骨架(朝向锁 / 正向措辞 / 装备存在无关 / 一次性的单次+终态保持)。
+ # 不能塞进下面那张表 —— 那张表里的 builder 只接 facing。
+ if action.action is ActionType.CUSTOM:
+ return build_custom_prompt(
+ action.custom_action or "",
+ facing=action.facing,
+ cyclic=bool(action.cyclic),
+ )
+ # attack 同样进不了那张表:它还要按运动拓扑选提示词分支。archetype 缺省时不在这里
+ # 兜一个默认值 —— 缺省只由 build_attack_prompt 定义一次,写两处会各自漂移。
+ if action.action is ActionType.ATTACK:
+ if action.archetype is None:
+ return build_attack_prompt(facing=action.facing)
+ return build_attack_prompt(facing=action.facing, archetype=action.archetype)
+ builders = {
+ ActionType.JUMP: build_jump_prompt,
+ ActionType.IDLE: build_idle_prompt,
+ }
+ build = builders.get(action.action, build_walk_prompt)
+ return build(facing=action.facing)
+
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ # 帧数直接读契约字段:缺省值已收进 ActionSpec(DEFAULT_N_FRAMES),不再由本层
+ # 用 `or 8` 兜底 —— 那等于把契约的缺省值写在实现里,换条 strategy 就换个默认值。
+ n = action.n_frames
+ # 进度文案里的枚举一律取 .value:Python 3.11+ 改了 str-mixin 枚举的 __format__,
+ # f"{action.action}" 现在给的是 "ActionType.WALK" 而不是 "walk"(3.12.13 实测),
+ # 而这串字会经 server 变成用户看到的 SSE 进度。
+ progress.step("derive", 0, 3, f"{action.action.value}: i2v 生成视频")
+ # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁
+ framed = prepare_master(master, action.action.value)
+ video = self._video.i2v(framed, self._build_prompt(action), seconds=5)
+
+ dense = extract_all_frames_bytes(video)
+ # 跨动作一致性:用视频首帧(=母版姿态)的角色高当共同定标基准。各动作都从同一母版
+ # 起手,故此值一致 —— 否则各动作按自己最高帧定标,切状态时角色会忽大忽小。
+ ref_h = None
+ if dense:
+ _first = _img(self._matte.cutout(_png(dense[0])))
+ _ys, _ = np.where(np.asarray(_first)[:, :, 3] > 128)
+ ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None
+ if is_cyclic(action):
+ progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图")
+ picked = pick_cycle(dense, n) # 单周期闭环(#21)
+ else:
+ progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图")
+ kind = "airborne" if action.action is ActionType.JUMP else "swing"
+ picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止
+ cut = [_img(self._matte.cutout(_png(im))) for im in picked]
+
+ # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色);
+ # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板,
+ # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。
+ if action.stylize is Stylize.NONE:
+ progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)")
+ return [_png(im) for im in cut]
+
+ target_h, palette = action.pixel_h, None
+ try:
+ logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的
+ if logical_h > 8: # 母版确为像素画 → 按它的规格走
+ target_h, palette = logical_h, pal
+ except Exception: # 母版非像素画/量不出 → 回退通用量化
+ pass
+ progress.step(
+ "derive", 2, 3,
+ f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})",
+ )
+ pix = pixelate_frames(
+ cut, target_h=target_h, palette_size=action.palette_size,
+ palette=palette, ref_height=ref_h,
+ )
+ return [_png(p) for p in pix]
+
+
+class PerFrameStrategy(DerivationStrategy):
+ """离散姿势(hit 等,需单帧可编辑):逐帧图生图 → 抠图。**未实现**(#53)。
+
+ 这条路线的价值在"单帧可重画",与 i2v 是不同的产品能力,不能拿 i2v 顶替。
+ """
+
+ route = GenRoute.PER_FRAME
+
+ def __init__(self, image: ImageProvider, matte: MatteProvider) -> None:
+ self._image = image
+ self._matte = matte
+
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ master: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ # 显式抛错,**不返回空帧**。曾经的桩实现 `return [b""] * n_frames` 会让调用方拿到
+ # 一个"帧数对、时长对、无异常"的 GeneratedAction —— server 照常把 N 个 0 字节文件
+ # 传上对象存储、写进 character_data,用户看到 N 张裂图,且排查时不会想到是路线没实现。
+ # 未实现就要在边界上炸,不能让空数据流下去。
+ raise NotImplementedError(
+ f"生成路线 {self.route.value} 尚未实现(动作 {action.action.value})。"
+ "见 1024XEngineer/Windup#53。"
+ )
+
+
+# Facing → 出帧台的朝向名。键名与前端导出模型的 ExportAction.sequences[].direction
+# 同域,不需要转换层。
+#
+# **值是真渲一遍量出来的,不能按方位名推。** 直觉上 "south = 朝观者",实际 n(yaw=90°)
+# 才是正面(奶白胸腹与口鼻可见,浅色像素占比 21.0%),s 是背面(8.5%)。两者主体像素数
+# 几乎相同,靠轮廓分不出正反,而单元测试也逮不到 —— 朝向错了但帧数、时长、成色全部正常。
+# 改这张表之前先渲一遍再量。
+_FACING_TO_DIRECTION: dict[Facing, str] = {
+ Facing.SIDE: "e", # yaw=0°,角色朝画面右(与出帧台 faces="right" 同口径)
+ Facing.FRONT: "n", # yaw=90°,身体正对观者
+}
+
+
+class RenderFrameStrategy(DerivationStrategy):
+ """三渲二:已绑骨的 3D 模型 → 套预设动作 → 渲 2D 序列帧。
+
+ **吃的是绑骨模型,不是母版图。** 图生 3D 与自动绑骨那两段按次计费、每造型一次性,
+ 由 server 侧的 ``Render3DAssetBuilder`` 负责(#121);走到这里钱已经花完,本段纯本地、
+ 零 API 成本。相对 i2v 的独占优势是**多朝向零成本且跨朝向天生一致**。
+ """
+
+ route = GenRoute.RENDER_3D
+
+ def __init__(
+ self,
+ renderer: SpriteRenderProvider,
+ *,
+ directions: int = 4,
+ material: str = "cel",
+ size: tuple[int, int] | None = None,
+ ) -> None:
+ # 出帧台的 provider 包只在这条路线上用得着(它连着 node + 浏览器)。在模块顶层
+ # import 会让走 i2v / 逐帧的部署也必须装齐它,而这两条路线一行都用不到。
+ from windup_framework.providers.render3d import RENDER_SIZE
+
+ self._renderer = renderer
+ self._directions = directions
+ self._material = material
+ self._size = size or RENDER_SIZE
+
+ def derive(
+ self,
+ card: CharacterCard,
+ action: ActionSpec,
+ rigged_model: bytes,
+ progress: ProgressPort,
+ ) -> list[bytes]:
+ if not rigged_model:
+ # 空模型必须在这里炸:让它流下去的话出帧台只报一句"Bad glTF",
+ # 排查方向会整个跑偏到出帧管线上。
+ raise ValueError(
+ f"三渲二拿到空的绑骨模型(角色 {card.name!r}、动作 {action.action.value})。"
+ "server 侧应在调用前确认该造型的 3D 资产可读。"
+ )
+
+ want = _FACING_TO_DIRECTION.get(action.facing, "e")
+ progress.step(
+ "derive", 0, 3,
+ f"渲 {self._directions} 朝向 × {action.n_frames} 帧"
+ f"({self._size[0]}×{self._size[1]},材质 {self._material})",
+ )
+ sheet: SpriteSheet = self._renderer.render(
+ rigged_model,
+ clip=action.action.value,
+ directions=self._directions,
+ frames=action.n_frames,
+ size=self._size,
+ material=self._material,
+ )
+
+ available = tuple(s.direction for s in sheet.sequences)
+ chosen = next((s for s in sheet.sequences if s.direction == want), None)
+ if chosen is None:
+ # 不静默换一个朝向交出去:朝向错了的序列帧就是角色朝反方向走,
+ # 而帧数、时长、成色全都正常,没有任何一道会红。
+ raise ValueError(
+ f"出帧台没有产出朝向 {want}(动作 {action.action.value}、facing "
+ f"{action.facing.value});实际产出 {available}。"
+ )
+ frames = list(chosen.frames)
+ if not frames:
+ raise ValueError(
+ f"三渲二未产出任何帧(动作 {action.action.value}、朝向 {chosen.direction})。"
+ )
+
+ extra = [d for d in available if d != chosen.direction]
+ if extra:
+ # 如实报:这些朝向已经渲出来了、零额外成本,但出参装不下,只能丢。
+ # 不写成 warning 日志而是进度文案,因为这串字最终会经 server 到用户眼前,
+ # 而"多朝向"正是这条路线的卖点 —— 用户该知道它已经算好了。
+ progress.step(
+ "derive", 1, 3,
+ f"已渲 {len(available)} 个朝向,本次出参只带 {chosen.direction};"
+ f"其余 {','.join(extra)} 零成本可用但当前契约装不下(#122)",
+ )
+ else:
+ progress.step("derive", 1, 3, f"朝向 {chosen.direction} 共 {len(frames)} 帧")
+
+ # 3D 帧本来就是透明底,**不套抠图**:去白边那一步会把浅灰甲当漏白吃掉。
+ # 像素化仍按 ActionSpec 走。
+ if action.stylize is Stylize.NONE:
+ progress.step("derive", 2, 3, "保留渲染画风(不像素化)")
+ return frames
+
+ # 像素化的目标高与色板本来该从母版量(master_pixel_spec),但本路线**没有母版** ——
+ # 它吃的是 3D 模型。所以只能按 ActionSpec 声明的 pixel_h + 通用量化走。
+ # 这是一处已知差异,不是遗漏:锁母版色板要靠母版像素,而这条路线上根本没有那张图。
+ progress.step("derive", 2, 3, f"像素化(h={action.pixel_h}·通用量化)")
+ pix = pixelate_frames(
+ [_img(f) for f in frames],
+ target_h=action.pixel_h,
+ palette_size=action.palette_size,
+ )
+ return [_png(p) for p in pix]
+
+
+# 注:曾有 ProcIdleStrategy(GenRoute.PROC_IDLE)—— 待机走"母版抠图 + 程序化局部躯干呼吸"
+# 的零 API 路线(Idle-B,#53 原设计)。**2026-08-07 定案放弃**:程序化呼吸做不出可用效果,
+# idle 统一走 i2v、认这份钱。GenRoute.PROC_IDLE 一并移除,不留没有实现的枚举值。
diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py
index fd997e30..fa38e225 100644
--- a/backend/packages/app/src/windup_app/bootstrap/app.py
+++ b/backend/packages/app/src/windup_app/bootstrap/app.py
@@ -7,27 +7,36 @@
``main`` 是开发启动入口:``python -m windup_app`` 或 ``windup`` 命令。
"""
+import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from windup_framework.db import Base, engine
+from windup_framework.db import Base, engine, SessionLocal
# 模型导入:触发 Base.metadata 注册,确保 create_all 能发现所有表
+from windup_ai_engine.impl.character_namer import LangChainCharacterNamer
from windup_app.server.character.model import Character # noqa: F401
+from windup_app.server.character.service import service as character_service
+from windup_app.server.orchestrator.dispatcher import GenerationDispatcher
from windup_app.server.project.model import Project # noqa: F401
+from windup_app.server.quota.model import CreditAccount, CreditTransaction, InviteCode, InviteRecord # noqa: F401
from windup_app.server.user.model import User # noqa: F401
from windup_app.server.workflow_run.model import WorkflowRun # noqa: F401
from windup_app.web.api.auth import router as auth_router
from windup_app.web.api.character import router as character_router
+from windup_app.server.orchestrator import task_repo
+from windup_app.server.orchestrator.executor import run_action_task, run_image_task
+from windup_app.server.orchestrator.render3d_service import default_operations, precheck_master
from windup_app.web.api.generation import router as generation_router
from windup_app.web.api.media import router as media_router
from windup_app.web.api.project import router as project_router
+from windup_app.web.api.quota import router as quota_router
+from windup_app.web.api.render3d import router as render3d_router
from windup_app.web.api.workflow_run import router as workflow_run_router
from windup_app.web.handler.exception_handlers import register_exception_handlers
from windup_app.web.middleware.auth import AuthMiddleware
-from windup_app.web.middleware.ratelimit import RateLimitMiddleware
def _env_flag(name: str) -> bool:
@@ -41,7 +50,7 @@ def _cors_origins() -> list[str]:
不配这个中间件的话,浏览器会把前端的**所有**请求拦在预检那一步
(OPTIONS 返回 405、响应无 access-control-* 头),后端日志里连请求都看不到。
- 默认值覆盖本地 dev server 与 Vercel 预览域名。
+ 默认值仅覆盖本地 dev server;远程来源必须显式配置。
"""
raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip()
if raw:
@@ -53,9 +62,9 @@ def _cors_origins() -> list[str]:
def _cors_origin_regex() -> str | None:
"""CORS 正则匹配的额外来源,WINDUP_CORS_ORIGIN_REGEX 覆盖。
- 默认允许所有 Vercel 预览域名。
+ 默认不允许正则来源,避免信任任意第三方托管子域名。
"""
- return os.getenv("WINDUP_CORS_ORIGIN_REGEX", r"https://.*\.vercel\.app").strip() or None
+ return os.getenv("WINDUP_CORS_ORIGIN_REGEX", "").strip() or None
def print_banner() -> None:
@@ -65,17 +74,51 @@ def print_banner() -> None:
@asynccontextmanager
async def _lifespan(app: FastAPI):
- """应用启动时建表 + 打印 banner,关闭时无特殊处理。"""
+ """应用启动时建表,关闭时等待已排队的生成任务收敛。"""
Base.metadata.create_all(engine)
print_banner()
- yield
+ _recover_orphaned_generation(app)
+ try:
+ yield
+ finally:
+ app.state.generation_dispatcher.shutdown()
+
+
+def _recover_orphaned_generation(app: FastAPI) -> None:
+ """启动时把仍冻结的 PENDING 任务重新入队,RUNNING 孤儿失败并解冻。"""
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ session = SessionLocal()
+ try:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=app.state.generation_dispatcher,
+ run_image_task=app.state.run_image_task,
+ run_action_task=app.state.run_action_task,
+ )
+ session.commit()
+ except Exception:
+ session.rollback()
+ logging.getLogger("windup.bootstrap").exception("生成任务对账失败")
+ finally:
+ session.close()
def create_app() -> FastAPI:
app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan)
- # 中间件(add_middleware 后加的先执行:请求先进 CORS → 再进 RateLimit → 再进 Auth → 最后到路由)
+ app.state.generation_dispatcher = GenerationDispatcher()
+ # 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。
+ # LangChainCharacterNamer 构造期不创建 ChatOpenAI;缺 AI_API_KEY 时应用仍能启动。
+ # 测试若已注入假 namer,不要覆盖。
+ if character_service._namer is None:
+ character_service._namer = LangChainCharacterNamer()
+
+ @app.get("/health", include_in_schema=False)
+ def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+ # 中间件(add_middleware 后加的先执行:请求先进 CORS → 再进 Auth → 最后到路由)
app.add_middleware(AuthMiddleware)
- app.add_middleware(RateLimitMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins(),
@@ -90,6 +133,22 @@ def create_app() -> FastAPI:
app.include_router(workflow_run_router)
app.include_router(media_router)
app.include_router(generation_router)
+ app.include_router(quota_router)
+ app.include_router(render3d_router)
+ # 生成任务的后台执行器挂到 app.state:端点只建 PENDING 记录立即返回,真正的
+ # 图生图/i2v 在后台线程跑。放在 state 而不是 import 到 web 层,是因为
+ # import-linter 的分层契约禁止 app.web 直连 ai_engine,而 executor 要调它。
+ app.state.run_action_task = run_action_task
+ app.state.run_image_task = run_image_task
+ # 母版预检与建 3D 资产同理:两者都经 ai_engine,web 层不能静态依赖。
+ # 预检是零成本纯函数;建资产要花钱,``default_operations`` 自带 WINDUP_RENDER3D_ALLOW_SPEND 开关。
+ app.state.precheck_master = precheck_master
+ app.state.render3d_operations = default_operations()
+
+ # task_repo 状态变更时自动推 SSE。延迟 import 避免与 generation 模块循环依赖。
+ from windup_app.web.api.generation import event_bus
+
+ task_repo.bind_event_bus(event_bus)
register_exception_handlers(app)
return app
diff --git a/backend/packages/app/src/windup_app/server/character/interface.py b/backend/packages/app/src/windup_app/server/character/interface.py
index 37341353..343257e5 100644
--- a/backend/packages/app/src/windup_app/server/character/interface.py
+++ b/backend/packages/app/src/windup_app/server/character/interface.py
@@ -32,11 +32,27 @@ def create_character(self, session: Session, **fields) -> Character:
def get_character(self, session: Session, character_id: int) -> Character | None:
"""按 ID 查询角色。"""
+ @abstractmethod
+ def get_character_by_workflow_run(
+ self,
+ session: Session,
+ workflow_run_id: int,
+ ) -> Character | None:
+ """按 WorkflowRun ID 查询唯一角色。"""
+
@abstractmethod
def list_characters(
self, session: Session, *, project_id: int, page: int, page_size: int,
+ status: int | None = None,
) -> tuple[list[Character], int]:
- """分页查询项目下的角色列表,返回 (当前页数据, 总数)。"""
+ """分页查询项目下的角色列表,返回 (当前页数据, 总数)。
+
+ ``status``: 可选,按发布状态过滤(0=草稿,1=已发布)。
+ """
+
+ @abstractmethod
+ def project_has_characters(self, session: Session, project_id: int) -> bool:
+ """判断项目下是否仍挂载角色(草稿或已发布均算)。"""
@abstractmethod
def update_character(self, session: Session, character_id: int, **fields) -> Character | None:
@@ -47,4 +63,4 @@ def update_character(self, session: Session, character_id: int, **fields) -> Cha
@abstractmethod
def delete_character(self, session: Session, character_id: int) -> bool:
- """删除角色并返回是否找到。"""
\ No newline at end of file
+ """删除角色并返回是否找到。"""
diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py
index 626336cb..b330df55 100644
--- a/backend/packages/app/src/windup_app/server/character/model.py
+++ b/backend/packages/app/src/windup_app/server/character/model.py
@@ -14,6 +14,7 @@
├── id str: 造型稳定 ID
├── name str: 造型名称
├── preview_url str | None: 造型预览图
+ ├── model_3d_url str | None: 该造型的绑骨 3D 模型(三渲二路线的开关)
└── actions[] list[CharacterAction]: 动作列表
├── id str: 动作稳定 ID
├── type "idle" | "walk" | "attack" | "custom"
@@ -35,10 +36,21 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
-from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, String, Text
+from sqlalchemy import (
+ BigInteger,
+ DateTime,
+ ForeignKey,
+ Integer,
+ JSON,
+ SmallInteger,
+ String,
+ Text,
+ UniqueConstraint,
+)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
+from windup_common.enums.character import CharacterStatus
from windup_framework.db import Base
@@ -49,6 +61,9 @@ class Character(Base):
"""角色资产表。"""
__tablename__ = "windup_character"
+ __table_args__ = (
+ UniqueConstraint("workflow_run_id", name="uq_windup_character_workflow_run"),
+ )
# Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走
# INTEGER PRIMARY KEY 自增。
@@ -58,7 +73,15 @@ class Character(Base):
autoincrement=True,
)
- project_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ project_id: Mapped[int] = mapped_column(
+ BigInteger,
+ ForeignKey(
+ "windup_project.id",
+ ondelete="RESTRICT",
+ name="fk_windup_character_project_id",
+ ),
+ nullable=False,
+ )
workflow_run_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
@@ -75,8 +98,9 @@ class Character(Base):
default=dict,
)
+ # HTTP 创建/更新会根据真实动作帧重新计算;草稿默认值保护其他服务写入不被误发布。
status: Mapped[int] = mapped_column(
- SmallInteger, nullable=False, default=1
+ SmallInteger, nullable=False, default=CharacterStatus.DRAFT
)
create_at: Mapped[datetime] = mapped_column(
@@ -122,6 +146,17 @@ class CharacterOutfit(BaseModel):
name: str = Field(..., description="造型名称")
description: str | None = Field(default=None, description="造型描述")
preview_url: str | None = Field(default=None, description="造型预览图 URL")
+ # 该造型的**绑骨 3D 模型**存储 URL;``None`` = 还没建。三渲二路线的开关就是它:
+ # server 读到有值就调 CharacterGeneratorPort.generate_rendered,读到 None 就走 i2v。
+ #
+ # 挂在造型一级而非角色一级(#121):外观挂在造型上(每个造型自带 preview_url),
+ # 角色级只有一张参考图,同一角色的不同造型共用不了一个 3D 模型。
+ #
+ # 建这份资产是**每造型一次性**的按次计费(图生 3D + 绑骨),不在动作生成的请求
+ # 路径上 —— 见 orchestrator.render3d_assets.Render3DAssetBuilder。
+ model_3d_url: str | None = Field(
+ default=None, description="该造型的绑骨 3D 模型 URL;None = 未建,三渲二不可用"
+ )
actions: list[CharacterAction] = Field(default_factory=list, description="该造型下的动作列表")
@@ -129,4 +164,4 @@ class CharacterData(BaseModel):
"""角色完整数据(造型→动作→帧)。"""
version: int = Field(default=1, description="结构版本")
- outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表")
\ No newline at end of file
+ outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表")
diff --git a/backend/packages/app/src/windup_app/server/character/naming.py b/backend/packages/app/src/windup_app/server/character/naming.py
new file mode 100644
index 00000000..6b5dff4a
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/character/naming.py
@@ -0,0 +1,42 @@
+"""创建角色时解析最终名称:用户输入优先,否则 LLM,再否则描述兜底。"""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+NAME_MAX_LEN = 20
+FALLBACK_NAME = "未命名角色"
+
+
+class CharacterNamer(Protocol):
+ """server 侧起名器契约,避免 web→service 链路碰到 ai_engine。"""
+
+ def name_from_description(self, description: str) -> str: ...
+
+
+def _clip(value: str) -> str:
+ return value[:NAME_MAX_LEN]
+
+
+def resolve_character_name(
+ name: str | None,
+ description: str | None,
+ namer: CharacterNamer | None = None,
+) -> str:
+ """把可空的 name / description 收成入库用的非空短名称。"""
+ cleaned_name = (name or "").strip()
+ if cleaned_name:
+ return _clip(cleaned_name)
+
+ cleaned_description = (description or "").strip()
+ if cleaned_description and namer is not None:
+ try:
+ generated = (namer.name_from_description(cleaned_description) or "").strip()
+ except Exception:
+ generated = ""
+ if generated:
+ return _clip(generated)
+
+ if cleaned_description:
+ return _clip(cleaned_description)
+ return FALLBACK_NAME
diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py
index e1ff22d4..d38723be 100644
--- a/backend/packages/app/src/windup_app/server/character/service.py
+++ b/backend/packages/app/src/windup_app/server/character/service.py
@@ -13,12 +13,29 @@
from windup_app.server.character.interface import CharacterService
from windup_app.server.character.model import Character
+from windup_app.server.character.naming import CharacterNamer, resolve_character_name
class SqlAlchemyCharacterService(CharacterService):
"""基于 SQLAlchemy session 的角色 CRUD 实现。"""
+ def __init__(self, namer: CharacterNamer | None = None) -> None:
+ self._namer = namer
+
def create_character(self, session: Session, **fields) -> Character:
+ fields = dict(fields)
+ workflow_run_id = fields.get("workflow_run_id")
+ existing = (
+ self.get_character_by_workflow_run(session, workflow_run_id)
+ if workflow_run_id is not None
+ else None
+ )
+ if existing is not None and existing.project_id == fields.get("project_id"):
+ return existing
+ name = fields.get("name")
+ # 已有同 workflow_run(含跨项目冲突)不再打 LLM,插入交给唯一约束。
+ namer = None if (name or "").strip() or existing is not None else self._namer
+ fields["name"] = resolve_character_name(name, fields.get("description"), namer)
character = Character(**fields)
session.add(character)
session.flush()
@@ -27,17 +44,30 @@ def create_character(self, session: Session, **fields) -> Character:
def get_character(self, session: Session, character_id: int) -> Character | None:
return session.get(Character, character_id)
+ def get_character_by_workflow_run(
+ self,
+ session: Session,
+ workflow_run_id: int,
+ ) -> Character | None:
+ stmt = select(Character).where(Character.workflow_run_id == workflow_run_id)
+ return session.scalar(stmt)
+
def list_characters(
self, session: Session, *, project_id: int, page: int, page_size: int,
+ status: int | None = None,
) -> tuple[list[Character], int]:
+ base_condition = Character.project_id == project_id
+ if status is not None:
+ base_condition = base_condition & (Character.status == status)
+
count_stmt = (
select(func.count())
.select_from(Character)
- .where(Character.project_id == project_id)
+ .where(base_condition)
)
stmt = (
select(Character)
- .where(Character.project_id == project_id)
+ .where(base_condition)
.order_by(Character.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@@ -46,6 +76,14 @@ def list_characters(
items = list(session.scalars(stmt))
return items, total
+ def project_has_characters(self, session: Session, project_id: int) -> bool:
+ stmt = (
+ select(Character.id)
+ .where(Character.project_id == project_id)
+ .limit(1)
+ )
+ return session.scalar(stmt) is not None
+
def update_character(
self, session: Session, character_id: int, **fields,
) -> Character | None:
diff --git a/backend/packages/app/src/windup_app/server/media/service.py b/backend/packages/app/src/windup_app/server/media/service.py
index daf66a2f..8e5b1133 100644
--- a/backend/packages/app/src/windup_app/server/media/service.py
+++ b/backend/packages/app/src/windup_app/server/media/service.py
@@ -22,6 +22,7 @@ def upload(
data: bytes,
metadata: MediaUploadInput,
) -> MediaUploadResult:
+ download_base = storage_settings.download_base
suffix = _file_suffix(metadata.filename)
object_key = f"media/{metadata.category}/{uuid4().hex}{suffix}"
@@ -39,7 +40,7 @@ def upload(
msg = f"七牛上传失败: status={resp.status_code}, body={resp.text}"
raise RuntimeError(msg)
- url = f"{storage_settings.download_base}/{object_key}"
+ url = f"{download_base}/{object_key}"
return MediaUploadResult(
url=url,
object_key=object_key,
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/__init__.py b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py
index c21a7b85..2cea0d63 100644
--- a/backend/packages/app/src/windup_app/server/orchestrator/__init__.py
+++ b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py
@@ -1,4 +1,9 @@
-"""生成任务领域。"""
+"""生成任务编排(orchestrator):提交 / 调度 / 查询生成任务。
+
+本包只做**任务编排调度**——建任务记录、后台驱动执行、查询状态;实际 AI 生成
+(调 ai_engine)在 :mod:`.executor` 后台跑。原名 ``generation``,更名为 ``orchestrator``
+以准确表达职责(调度而非生成本身)。
+"""
from windup_app.server.orchestrator.model import (
ActionType,
@@ -7,9 +12,12 @@
CharacterActionOutput,
CharacterImageInput,
GenerationTask,
+ GenerationTaskRecord,
GenerationType,
TaskStatus,
)
+from windup_app.server.orchestrator.service import service as generation_service
+from windup_app.server.orchestrator import task_repo
__all__ = [
"ActionType",
@@ -18,6 +26,9 @@
"CharacterActionOutput",
"CharacterImageInput",
"GenerationTask",
+ "GenerationTaskRecord",
"GenerationType",
"TaskStatus",
+ "generation_service",
+ "task_repo",
]
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/_fetch.py b/backend/packages/app/src/windup_app/server/orchestrator/_fetch.py
new file mode 100644
index 00000000..0d8e1403
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/_fetch.py
@@ -0,0 +1,71 @@
+"""受限的服务端取图 —— 只允许拉自家对象存储,且限响应体大小。
+
+为什么需要它(2026-08-10,机器审逮到):``executor`` 原先直接
+``httpx.get(input.reference_image_urls[0])``,而那个 URL 来自已认证请求的请求体。
+服务端替调用方发起请求,等于把服务器当跳板:
+
+- ``http://127.0.0.1:8000/...`` 打到自己身上,绕过鉴权中间件访问内网端点;
+- 云环境的实例元数据服务(各家都是一个固定的 link-local 地址)会吐出临时凭证;
+- 私网地址段可以拿来探测内网拓扑;
+- 重定向能把一个看起来合法的域名换成上面任意一种,所以**必须禁跟随重定向**;
+- 响应体无上限时,一个指向巨大文件的 URL 就能把 worker 的内存吃光。
+
+设计取向是**白名单**而不是黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、
+169.254/16、::1、fc00::/7、以及各种十进制/八进制/IPv6-mapped 写法,漏一条就等于没做。
+而这里的业务只需要拉自家 bucket 的图(母版与参考图都是先经 ``/media/upload`` 传上去的),
+所以直接卡"必须是 ``storage_settings.download_base`` 前缀"。
+
+代价:调用方不能再传外部图床链接。这是刻意的——真要支持,该走一个显式的"导入外部素材"
+入口,在那里做完整的地址校验与配额,而不是让生成链路顺手具备任意 URL 抓取能力。
+"""
+from __future__ import annotations
+
+import httpx
+
+from windup_framework.config.storage import settings as storage_settings
+
+__all__ = ["MAX_FETCH_BYTES", "FetchNotAllowed", "fetch_own_media"]
+
+# 单张图的上限。母版是 1024² 级的 PNG(实测 860~970 KB),16 MiB 留了足够余量,
+# 又不至于让一个恶意 URL 拖垮 worker 内存。
+MAX_FETCH_BYTES = 16 * 1024 * 1024
+
+
+class FetchNotAllowed(ValueError):
+ """URL 不在允许范围内,或响应体超限。属调用方输入问题(4xx),不该重试。"""
+
+
+def fetch_own_media(url: str, *, timeout: float = 30.0) -> bytes:
+ """取自家对象存储上的一张图。非自家地址、重定向、超大响应一律拒绝。"""
+ base = storage_settings.download_base
+ if not base:
+ raise FetchNotAllowed(
+ "对象存储下载域名未配置(WINDUP_STORAGE_BUCKET_DOMAIN),无法校验来源"
+ )
+ if not url.startswith(f"{base}/"):
+ raise FetchNotAllowed(
+ f"只允许拉自家对象存储({base})上的素材,收到 {url[:80]!r}。"
+ "外部图片请先经 POST /media/upload 传入。"
+ )
+
+ # follow_redirects=False:跟随重定向会让白名单失效 —— 自家域名返回 302 指向
+ # 元数据服务,校验就白做了。自家 bucket 直读不需要重定向。
+ with httpx.Client(timeout=timeout, follow_redirects=False) as client:
+ with client.stream("GET", url) as resp:
+ resp.raise_for_status()
+ declared = resp.headers.get("content-length")
+ if declared and int(declared) > MAX_FETCH_BYTES:
+ raise FetchNotAllowed(
+ f"素材 {int(declared)} 字节,超过上限 {MAX_FETCH_BYTES}"
+ )
+ # Content-Length 可以缺失或撒谎,故边读边计数。
+ chunks: list[bytes] = []
+ total = 0
+ for chunk in resp.iter_bytes():
+ total += len(chunk)
+ if total > MAX_FETCH_BYTES:
+ raise FetchNotAllowed(
+ f"素材超过上限 {MAX_FETCH_BYTES} 字节(已读 {total})"
+ )
+ chunks.append(chunk)
+ return b"".join(chunks)
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/billing.py b/backend/packages/app/src/windup_app/server/orchestrator/billing.py
new file mode 100644
index 00000000..3a1bf2b3
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/billing.py
@@ -0,0 +1,91 @@
+"""生成任务的预付费积分:提交冻结,成功扣减,失败解冻。
+
+``ref_id`` 固定为 ``task:{task_id}``,与流水表 ``(ref_id, reason)`` 唯一约束对齐。
+结算金额一律取提交时写入的 FROZEN 流水,不读当前定价。
+"""
+
+from __future__ import annotations
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from windup_common.enums.biz_code import BizCode
+from windup_common.enums.quota import CreditReason
+from windup_common.exceptions import BizException
+from windup_framework.config.quota import settings as quota_settings
+
+from windup_app.server.orchestrator.model import GenerationType
+from windup_app.server.quota.model import CreditTransaction
+from windup_app.server.quota.service import service as quota_service
+
+
+def credit_ref_id(task_id: int) -> str:
+ return f"task:{task_id}"
+
+
+def prepaid_cost(task_type: GenerationType) -> int:
+ if task_type is GenerationType.CHARACTER_IMAGE:
+ return quota_settings.generate_image_cost
+ if task_type is GenerationType.CHARACTER_ACTION:
+ return quota_settings.generate_action_cost
+ raise ValueError(f"未知生成类型: {task_type}")
+
+
+def frozen_amount_for_task(session: Session, task_id: int) -> int:
+ """读取提交时冻结的额度(FROZEN 流水 ``delta`` 的绝对值)。"""
+ txn = session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.ref_id == credit_ref_id(task_id),
+ CreditTransaction.reason == int(CreditReason.FROZEN),
+ )
+ )
+ if txn is None:
+ raise BizException("找不到该任务的冻结流水", code=BizCode.NOT_FOUND)
+ return abs(txn.delta)
+
+
+def has_open_freeze(session: Session, task_id: int) -> bool:
+ """仍有未 capture / 未 release 的预付费冻结。"""
+ frozen = session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.ref_id == credit_ref_id(task_id),
+ CreditTransaction.reason == int(CreditReason.FROZEN),
+ )
+ )
+ if frozen is None:
+ return False
+ captured = session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.ref_id == credit_ref_id(task_id),
+ CreditTransaction.reason == int(CreditReason.CAPTURED),
+ )
+ )
+ released = session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.ref_id == f"{credit_ref_id(task_id)}:release",
+ CreditTransaction.reason == int(CreditReason.REFUND),
+ )
+ )
+ return captured is None and released is None
+
+
+def reserve_for_task(
+ session: Session, *, user_id: int, task_id: int, task_type: GenerationType,
+) -> None:
+ quota_service.reserve_credit(
+ session, user_id, prepaid_cost(task_type), credit_ref_id(task_id),
+ )
+
+
+def capture_for_task(session: Session, *, user_id: int, task_id: int) -> None:
+ amount = frozen_amount_for_task(session, task_id)
+ quota_service.capture_credit(
+ session, user_id, amount, credit_ref_id(task_id), amount,
+ )
+
+
+def release_for_task(session: Session, *, user_id: int, task_id: int) -> None:
+ amount = frozen_amount_for_task(session, task_id)
+ quota_service.release_credit(
+ session, user_id, amount, credit_ref_id(task_id),
+ )
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/dispatcher.py b/backend/packages/app/src/windup_app/server/orchestrator/dispatcher.py
new file mode 100644
index 00000000..20efcb10
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/dispatcher.py
@@ -0,0 +1,21 @@
+"""进程内生成任务调度器。"""
+
+from collections.abc import Callable
+from concurrent.futures import Future, ThreadPoolExecutor
+from typing import Any
+
+
+class GenerationDispatcher:
+ """串行执行付费生成任务,避免并发触发上游限流。"""
+
+ def __init__(self) -> None:
+ self._executor = ThreadPoolExecutor(
+ max_workers=1,
+ thread_name_prefix="windup-generation",
+ )
+
+ def submit(self, target: Callable[..., Any], *args: Any) -> Future[Any]:
+ return self._executor.submit(target, *args)
+
+ def shutdown(self) -> None:
+ self._executor.shutdown(wait=True)
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py
new file mode 100644
index 00000000..e96e6f71
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py
@@ -0,0 +1,598 @@
+"""动作生成后台编排(调 ai_engine)。
+
+编排链:``mark RUNNING → 取母版 → ai_engine 出帧 → 逐帧上传对象存储 → 写回结果/COMPLETED``。
+异常兜底为 FAILED,不抛。
+
+**分层**:本模块调 ai_engine,故 web/worker **不得 import 本模块**(否则牵出 ai_engine,
+违反"入口层不经 ai_engine 直连"门禁)。由 bootstrap(composition root)import + 注入
+``app.state``,web 端从 ``request.app.state`` 运行期取回调度,不产生静态依赖。
+
+依赖(generator / upload / 取母版 / session 工厂)全可注入,缺省用真实实现(懒加载,
+避免 import-time 触发 AI 配置)。测试注入桩即可离线跑通,不联网、不碰对象存储。
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from sqlalchemy.orm import Session
+
+from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard
+
+from windup_app.server.orchestrator import billing, task_repo
+from windup_app.server.orchestrator._fetch import fetch_own_media
+from windup_app.server.orchestrator.model import (
+ CharacterActionInput,
+ CharacterImageInput,
+ TaskStatus,
+)
+
+if TYPE_CHECKING:
+ from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort
+ from windup_framework.providers import ImageProvider, MatteProvider
+
+logger = logging.getLogger("windup.generation.executor")
+
+_ACTION_RESULT = "character_action" # task_repo._deserialize_result 按此标签反序列化
+
+
+def _settle_credit(session: Session, task_id: int, *, success: bool) -> None:
+ """任务终态时结清预付费:成功扣减,失败解冻。"""
+ task = task_repo.get_task(session, task_id)
+ if task is None or task.id is None:
+ return
+ if success:
+ billing.capture_for_task(session, user_id=task.user_id, task_id=task.id)
+ else:
+ billing.release_for_task(session, user_id=task.user_id, task_id=task.id)
+
+# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ─────────────────────────
+# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角
+_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"}
+_PERSPECTIVE_VIEW: dict[int, str] = {
+ 1: "side view, horizontal side-scroller",
+ 2: "top-down view",
+ 3: "2.5D three-quarter view",
+}
+# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数
+_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8}
+
+
+@dataclass
+class ProjectConstraints:
+ """从 Project 取的全局生成约束,统一约束角色图/动作生成。"""
+
+ facing: str = "side" # character_perspective → 朝向(须与母版一致 #35)
+ view: str = "side view, horizontal side-scroller"
+ perspective: int = 1 # 1横版 2俯视 3 2.5D
+ directions: int = 1 # directional_movement → 方向数(1/4/8)
+ sprite_w: int = 256 # 输出/切帧尺寸(关键)
+ sprite_h: int = 256
+ style: str = "" # game_style 画风
+ stylize: str = "none" # 由 style 推:像素游戏 → pixel
+ sprite_sample_url: str = "" # 项目风格参考图 URL
+
+
+def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints:
+ """查 Project 组装全局约束;无 project_id / 查不到 → 缺省。"""
+ if project_id is None:
+ return ProjectConstraints()
+ from windup_app.server.project.service import SqlAlchemyProjectService
+
+ p = SqlAlchemyProjectService().get_project(session, project_id)
+ if p is None:
+ return ProjectConstraints()
+ style = p.game_style or ""
+ is_pixel = "pixel" in style.lower() or "像素" in style
+ return ProjectConstraints(
+ facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"),
+ view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]),
+ perspective=p.character_perspective,
+ directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1),
+ sprite_w=p.sprite_width,
+ sprite_h=p.sprite_height,
+ style=style,
+ stylize="pixel" if is_pixel else "none",
+ sprite_sample_url=p.sprite_sample_url or "",
+ )
+
+
+def _fit_to(png: bytes, w: int, h: int, *, smooth: bool = False) -> bytes:
+ """把图等比缩放进 w×h(透明补边),落实尺寸约束。
+
+ ``smooth`` 决定重采样:序列帧是像素画,必须 NEAREST(插值会把硬边糊成灰边、
+ 并引入调色板外的颜色);全彩角色母版反过来,NEAREST 缩图会明显锯齿,用 LANCZOS。
+ """
+ import io
+
+ from PIL import Image
+
+ im = Image.open(io.BytesIO(png)).convert("RGBA")
+ if im.size == (w, h):
+ return png
+ fitted = im.copy()
+ fitted.thumbnail((w, h), Image.LANCZOS if smooth else Image.NEAREST)
+ canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2))
+ buf = io.BytesIO()
+ canvas.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def _require_size(png: bytes, w: int, h: int) -> bytes:
+ """核对引擎交付帧确实是项目要的尺寸,不对就报错 —— **不做静默补救**。
+
+ 这里以前是 ``_fit_to``:尺寸对不上就缩放补边。看着稳,实际是把"引擎没按尺寸出帧"
+ 这件事悄悄抹平,代价是脚线对齐被破坏(见 ``_produce_action`` 的说明)。尺寸现在由
+ 引擎按 ``canvas`` 负责,对不上说明生成侧出了问题,该让它响,而不是交付一批对齐
+ 坏掉的帧 —— 那正是本仓最忌讳的"看起来成功的错产物"。
+ """
+ import io
+
+ from PIL import Image
+
+ size = Image.open(io.BytesIO(png)).size
+ if size != (w, h):
+ raise ValueError(
+ f"引擎交付帧尺寸 {size[0]}×{size[1]} 与项目约束 {w}×{h} 不一致;"
+ "生成侧未按 canvas 出帧,不做静默缩放补救。"
+ )
+ return png
+
+
+class _LogProgress:
+ """进度上报占位:MVP 无 SSE,记日志即可。"""
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ logger.info("[gen] %s %s/%s %s", stage, i, total, note)
+
+
+# 白名单而不是放开任意模型名:每个模型的入参形状不同(image_list / input_reference /
+# Fal 队列 + `Authorization: Key`)。列进来却没适配它的协议,等于"看起来能选、点了必然
+# 产生一个用不了的付费任务"。只列 SufyVideoProvider 真能建单的。Refs #239。
+ALLOWED_VIDEO_MODELS: dict[str, str] = {
+ "kling-v2-5-turbo": "默认。稳,本地首帧即可",
+ "kling-v2-6": "有 motion-control",
+}
+
+
+def _resolve_video_model(name: str | None) -> str | None:
+ """校验并返回视频模型名;``None`` 表示用部署默认值。
+
+ 非法取值在入口炸,不等到付费调用才失败。
+ """
+ if name is None:
+ return None
+ if name not in ALLOWED_VIDEO_MODELS:
+ raise ValueError(
+ f"视频模型 {name!r} 不在本期开放列表内。可选:"
+ + ";".join(f"{k}({v})" for k, v in ALLOWED_VIDEO_MODELS.items())
+ )
+ return name
+
+
+def _to_engine_action(t) -> EngineActionType:
+ """generation.ActionType → 引擎 common.ActionType(按值映射)。
+
+ walk/idle/attack/**custom** 直通(custom 自 #239 起引擎已支持)。
+ 引擎仍未覆盖的类型在此抛带原因的错误,而不是让请求走到一半失败。
+ """
+ try:
+ return EngineActionType(t.value)
+ except ValueError as e:
+ raise ValueError(f"动作类型 {t.value!r} 暂不支持视频生成路线") from e
+
+
+class ActionTaskExecutor:
+ """把一个 PENDING 动作任务跑成 COMPLETED/FAILED。"""
+
+ def __init__(
+ self,
+ *,
+ generator: CharacterGeneratorPort | None = None,
+ upload: Callable[[bytes], str] | None = None,
+ fetch_master: Callable[[CharacterActionInput], bytes] | None = None,
+ fetch_model3d: Callable[[str], bytes] | None = None,
+ fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None,
+ session_factory: Callable[[], Session] | None = None,
+ ) -> None:
+ self._generator = generator # None → 懒加载真实装配
+ # 按视频模型名分桶的 generator 缓存(模型是 provider 的构造参数,不能事后换)
+ self._by_model: dict[str | None, CharacterGeneratorPort] = {}
+ # 抠图 / 图生图 provider 与视频模型无关,所有模型桶共用一份:每个抠图实例都会
+ # 各自惰性加载一份 ONNX 会话,按桶各建等于把同一个模型在进程里装多次。
+ self._matte: MatteProvider | None = None
+ self._image: ImageProvider | None = None
+ # 本执行器是进程级单例,而每个请求起一个线程跑 run_action_task,上面几个缓存
+ # 都是跨线程共用的可变状态。缺锁时并发首请求会各装一套(见 _get_generator)。
+ self._assembly_lock = threading.Lock()
+ self._upload = upload # None → 真实对象存储上传
+ self._fetch_master = fetch_master # None → 下载 reference_image_urls[0]
+ self._fetch_model3d = fetch_model3d # None → 下载 input.model_3d_url
+ self._fetch_constraints = fetch_constraints # None → 查 project 全局约束
+ self._session_factory = session_factory # None → SessionLocal
+
+ def run_action_task(
+ self,
+ task_id: int,
+ input: CharacterActionInput,
+ project_id: int | None = None,
+ *,
+ session: Session | None = None,
+ ) -> None:
+ """跑一个动作任务;异常兜底为 FAILED,不抛。
+
+ 先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session``
+ 缺省时自开一个(后台场景);测试可传入自己的 session。
+ """
+ own = session is None
+ session = session or self._make_session()
+ try:
+ task_repo.update_status(session, task_id, TaskStatus.RUNNING)
+ if own:
+ session.commit()
+
+ cons = (self._fetch_constraints or _load_constraints)(session, project_id)
+ result = self._produce_action(input, cons)
+ task_repo.update_result(session, task_id, _ACTION_RESULT, result)
+ _settle_credit(session, task_id, success=True)
+ if own:
+ session.commit()
+ except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常
+ logger.exception("动作任务 %s 失败", task_id)
+ session.rollback()
+ task_repo.update_status(
+ session, task_id, TaskStatus.FAILED, error_message=str(exc),
+ )
+ _settle_credit(session, task_id, success=False)
+ if own:
+ session.commit()
+ finally:
+ if own:
+ session.close()
+
+ # -- 内部 --------------------------------------------------------------
+
+ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict:
+ """母版 → ai_engine 按项目尺寸出帧 → 逐帧上传 → 组结果 dict。
+
+ 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、
+ 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向,
+ 四向/八向为扩展(需多次生成或镜像)。
+
+ **尺寸是传给引擎的,不是拿到帧再缩的。** 这里曾对每帧再做一次
+ ``_fit_to(png, sprite_w, sprite_h)``:引擎恒出 256,项目要 512 就等于二次
+ 重采样。而 ``_fit_to`` 用 ``Image.thumbnail`` —— 它**只缩不放**,放大方向
+ 根本不放大,只是把 256 的帧原尺寸居中贴进 512 画布,于是引擎刚对齐好的脚线
+ 0.92 被挪到 0.709,角色不站在地上、跨动作对齐一并失效。
+ 现在把 ``canvas`` 交给引擎,它一次就出到项目尺寸,那一步整个不存在了。
+ """
+ if cons.directions > 1:
+ logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions)
+ # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字
+ desc_parts = [input.custom_prompt or ""]
+ if cons.style:
+ desc_parts.append(f"Art style: {cons.style}")
+ card = CharacterCard(name=f"char-{input.character_id}", desc=" ".join(desc_parts))
+ engine_action = _to_engine_action(input.action_type)
+ # custom 的动作内容与循环性是 ActionSpec 的必填字段。但 cyclic 由本层补上默认值,
+ # 所以 ActionSpec 里那道 `cyclic is None` 守卫拦不到走这条路径的请求 —— 它保的是
+ # 其他直接构造 ActionSpec 的调用方。
+ extra: dict[str, object] = {}
+ if engine_action is EngineActionType.CUSTOM:
+ # 缺 loop 时兜成一次性,依据是失败代价不对称:一次性误当循环会让末帧接回首帧
+ # 抽搐、产物不可用;反之只是不无缝闭环、仍可用。不从描述文字猜。
+ cyclic = False if input.loop is None else bool(input.loop)
+ extra = {"custom_action": input.custom_prompt or "", "cyclic": cyclic}
+ action = ActionSpec(
+ action=engine_action,
+ poses=[""] * input.num_frames,
+ facing=cons.facing,
+ stylize=cons.stylize,
+ **extra,
+ )
+ progress: ProgressPort = _LogProgress()
+ canvas = (cons.sprite_w, cons.sprite_h)
+
+ # ── 路线选择:这一步是 server 的事,不是引擎的(#122)────────────────
+ #
+ # 判据就一条:这个造型有没有绑骨 3D 模型(character_data.outfits[].model_3d_url,
+ # 由 web 层读出来放进 input)。有 → 三渲二;没有 → 照旧 i2v。
+ #
+ # **不静默回退。** 拿到了 model_3d_url 却下载不下来 / 渲不出来,就报错,不改走
+ # i2v —— 两条路线的画风、成本、多朝向能力都不同,悄悄换一条等于让调用方拿着
+ # 错误的前提做后续决定,而帧数、时长、成色全都正常,没有任何一道会红。
+ model_url = (input.model_3d_url or "").strip()
+ if model_url:
+ rigged = (self._fetch_model3d or self._download_model3d)(model_url)
+ logger.info(
+ "[gen] 造型 %s 有 3D 资产(%d bytes),走三渲二",
+ input.outfit_id or "?", len(rigged),
+ )
+ generated = self._get_generator(
+ _resolve_video_model(input.video_model)).generate_rendered(
+ card, action, rigged, progress, canvas=canvas
+ )
+ else:
+ master = (self._fetch_master or self._download_master)(input)
+ generated = self._get_generator(
+ _resolve_video_model(input.video_model)).generate(
+ card, action, master, progress, canvas=canvas
+ )
+
+ upload = self._upload or self._upload_frame
+ frames = [
+ {"index": i,
+ "image_url": upload(_require_size(png, cons.sprite_w, cons.sprite_h)),
+ "duration_ms": dur}
+ for i, (png, dur) in enumerate(zip(generated.frames, generated.durations))
+ ]
+ return {"type": "character_action", "action_type": input.action_type.value, "frames": frames}
+
+ def _get_generator(self, video_model: str | None = None) -> CharacterGeneratorPort:
+ """懒装配 CharacterGenerator,按模型名分桶。
+
+ 视频 provider 的模型是构造参数,不分桶的话第一个请求指定的模型会被后续所有请求
+ 沿用,而调用方以为自己指定了。
+ """
+ if self._generator is not None:
+ return self._generator
+ # 命中缓存的快路径不进锁,否则每个请求都要在这里排一次队。只有装配新桶才上锁,
+ # 锁内重查一次:两个线程同时错过同一个桶时,后进来的那个要看见前一个的成果。
+ cached = self._by_model.get(video_model)
+ if cached is not None:
+ return cached
+ with self._assembly_lock:
+ cached = self._by_model.get(video_model)
+ if cached is None:
+ cached = self._assemble(video_model)
+ self._by_model[video_model] = cached
+ return cached
+
+ def _assemble(self, video_model: str | None) -> CharacterGeneratorPort:
+ """装一个模型桶。**调用方须持有 ``self._assembly_lock``**(会写共用 provider)。"""
+ from windup_ai_engine.impl import CharacterGenerator
+ from windup_ai_engine.strategy.concrete import (
+ PerFrameStrategy,
+ VideoFrameStrategy,
+ )
+ from windup_common.models import GenRoute
+ from windup_framework.providers import (
+ OnnxU2NetMatteProvider,
+ SufyImageProvider,
+ SufyVideoProvider,
+ )
+
+ if self._matte is None:
+ self._matte = OnnxU2NetMatteProvider()
+ if self._image is None:
+ self._image = SufyImageProvider()
+ # 只有它随模型变 —— 模型是构造参数,换模型必须换实例。
+ video = SufyVideoProvider(model=video_model)
+ # 装配表必须与 GenRoute 对齐。下面那条断言让漏装在装配时暴露,而不是等到某个
+ # 动作第一次被请求时才炸——注入 generator 的测试走不到这条装配路径,漏了会测试
+ # 全绿而真实调用全崩。
+ strategies = {
+ GenRoute.VIDEO_I2V: VideoFrameStrategy(video, self._matte),
+ GenRoute.PER_FRAME: PerFrameStrategy(self._image, self._matte),
+ GenRoute.RENDER_3D: self._build_render3d(),
+ }
+ missing = set(GenRoute) - set(strategies)
+ if missing:
+ raise RuntimeError(
+ f"GenRoute 新增了 {sorted(r.value for r in missing)} 但 executor 未装配;"
+ "补上或在此显式说明为何不装。"
+ )
+ return CharacterGenerator(strategies)
+
+ @staticmethod
+ def _build_render3d():
+ """三渲二的**渲帧**那一段。纯本地(node + playwright + three.js),零 API 成本。
+
+ 真被请求时才 import 出帧台那套依赖:它只有这条路线用得着,装配期就要齐会让本来
+ 走 i2v 的任务也因为它没配好而起不来。
+
+ **图生 3D 与绑骨那两段不在这里** —— 它们按次计费、每造型一次性,由
+ ``render3d_assets.Render3DAssetBuilder`` 在请求路径之外做(带一道人工确认停点),
+ 产物 URL 落在 ``outfits[].model_3d_url`` 上。捆进来就等于一个 web 请求能顺手扣钱。
+ """
+ from windup_ai_engine.strategy.base import DerivationStrategy
+ from windup_common.models import GenRoute
+
+ class _LazyRenderStrategy(DerivationStrategy):
+ route = GenRoute.RENDER_3D
+
+ def __init__(self) -> None:
+ self._inner: DerivationStrategy | None = None
+
+ def derive(self, card, action, source, progress):
+ if self._inner is None:
+ from windup_ai_engine.strategy.concrete import RenderFrameStrategy
+ from windup_framework.providers.render3d import LocalSpriteRenderProvider
+
+ self._inner = RenderFrameStrategy(LocalSpriteRenderProvider())
+ return self._inner.derive(card, action, source, progress)
+
+ return _LazyRenderStrategy()
+
+ def _download_model3d(self, url: str) -> bytes:
+ """取该造型的绑骨 3D 模型。走 ``fetch_own_media`` —— 与母版同一条受限通路
+ (只允许本站对象存储的域名,防 SSRF)。模型动辄二三十 MB,但和母版一样是
+ **一次性下载、进内存、喂引擎**,不落 ai_engine 的存储(它只吃 bytes)。
+ """
+ return fetch_own_media(url)
+
+ def _download_master(self, input: CharacterActionInput) -> bytes:
+ if not input.reference_image_urls:
+ raise ValueError("缺少母版:reference_image_urls 为空")
+ # 只允许拉自家对象存储:这个 URL 来自请求体,直接 httpx.get 等于把服务器
+ # 当跳板(可打 loopback / 云元数据服务 / 私网)。详见 _fetch 模块 docstring。
+ return fetch_own_media(input.reference_image_urls[0])
+
+ def _upload_frame(self, png: bytes) -> str:
+ from windup_app.server.media.model import MediaCategory, MediaUploadInput
+ from windup_app.server.media.service import service as media_service
+
+ meta = MediaUploadInput(
+ filename="frame.png",
+ content_type="image/png",
+ size=len(png),
+ category=MediaCategory.ACTION_FRAME,
+ )
+ return media_service.upload(png, meta).url
+
+ def _make_session(self) -> Session:
+ if self._session_factory is not None:
+ return self._session_factory()
+ from windup_framework.db.session import SessionLocal
+
+ return SessionLocal()
+
+
+_IMAGE_RESULT = "character_image" # task_repo._deserialize_result 按此标签反序列化
+
+
+class ImageTaskExecutor:
+ """跑角色图片生成任务:参考图 + prompt → 图生图 → 上传 → 回写 image_url。"""
+
+ def __init__(
+ self,
+ *,
+ image=None, # None → 懒加载 SufyImageProvider
+ upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传
+ fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url
+ session_factory: Callable[[], Session] | None = None,
+ ) -> None:
+ self._image = image
+ self._upload = upload
+ self._fetch_ref = fetch_ref
+ self._session_factory = session_factory
+
+ def run_image_task(
+ self,
+ task_id: int,
+ input: CharacterImageInput,
+ project_id: int | None = None,
+ *,
+ session: Session | None = None,
+ ) -> None:
+ own = session is None
+ session = session or self._make_session()
+ try:
+ task_repo.update_status(session, task_id, TaskStatus.RUNNING)
+ if own:
+ session.commit()
+ cons = _load_constraints(session, project_id) # 角色图也受项目约束
+ urls = self._produce_image(input, cons)
+ task_repo.update_result(session, task_id, _IMAGE_RESULT, {
+ "type": "character_image",
+ "image_urls": urls,
+ })
+ _settle_credit(session, task_id, success=True)
+ if own:
+ session.commit()
+ except Exception as exc: # noqa: BLE001 —— 兜底
+ logger.exception("图片任务 %s 失败", task_id)
+ session.rollback()
+ task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc))
+ _settle_credit(session, task_id, success=False)
+ if own:
+ session.commit()
+ finally:
+ if own:
+ session.close()
+
+ def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]:
+ """根据项目约束决定生成模式,返回 URL 列表。
+
+ 模式判断:
+ - 项目有 sprite_sample_url → **图生图**: 风格参考图 + 提示词
+ - 项目无 sprite_sample_url → **文生图**: 纯提示词
+ 用户传入的 reference_image_url 始终作为角色一致性参考(可选)。
+ """
+ fetch = self._fetch_ref or self._download
+ refs: list[bytes] = []
+ has_style_ref = False
+
+ # 1. 角色参考图(用户传入,可选,做角色一致性约束)
+ char_url = (input.reference_image_url or "").strip()
+ if char_url and char_url.lower() not in ("null", "none", ""):
+ refs.append(fetch(char_url))
+
+ # 2. 风格参考图(项目级,有 sprite_sample_url 时走图生图模式)
+ style_url = (cons.sprite_sample_url or "").strip()
+ if style_url and style_url.lower() not in ("null", "none", ""):
+ try:
+ refs.append(fetch(style_url))
+ has_style_ref = True
+ except Exception:
+ pass # 风格参考图下载失败不阻断
+
+ # 3. 构建提示词
+ base = input.prompt or "Clean full-body character reference of the figure in the image."
+ parts = [base, f"{cons.view}, full body head to feet, centered."]
+ if cons.style:
+ parts.append(f"Art style: {cons.style}.")
+ parts.append("Plain light-gray background, no shadow.")
+
+ # 图生图模式:明确标注两张图的各自用途
+ if has_style_ref:
+ prefix = (
+ "This is an image-to-image task. "
+ "The first image is the CHARACTER reference — preserve its identity. "
+ "The second image is the STYLE reference — follow its art style, "
+ "color palette, and rendering technique. "
+ )
+ parts.insert(0, prefix)
+
+ prompt = " ".join(parts)
+
+ image_gen = self._get_image()
+ upload = self._upload or self._upload_image
+ urls: list[str] = []
+ for _ in range(max(1, input.num_images)):
+ img = image_gen.gen_image(prompt, refs)
+ # 请求里的 width/height 此前被丢掉:入口收下并校验过它们(_validate_project_size),
+ # 而 ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大 —— 又一个"接了不
+ # 履约"的字段。模型本身不吃宽高,所以在这里落实。
+ urls.append(upload(_fit_to(img, input.width, input.height, smooth=True)))
+ return urls
+
+ def _get_image(self):
+ if self._image is None:
+ from windup_framework.providers import SufyImageProvider
+
+ self._image = SufyImageProvider()
+ return self._image
+
+ def _download(self, url: str) -> bytes:
+ # 同 _download_master:参考图 URL 由调用方给,必须走白名单取图。
+ return fetch_own_media(url)
+
+ def _upload_image(self, png: bytes) -> str:
+ from windup_app.server.media.model import MediaCategory, MediaUploadInput
+ from windup_app.server.media.service import service as media_service
+
+ meta = MediaUploadInput(
+ filename="character.png", content_type="image/png",
+ size=len(png), category=MediaCategory.REFERENCE_IMAGE,
+ )
+ return media_service.upload(png, meta).url
+
+ def _make_session(self) -> Session:
+ if self._session_factory is not None:
+ return self._session_factory()
+ from windup_framework.db.session import SessionLocal
+
+ return SessionLocal()
+
+
+# 默认执行器(真实依赖);bootstrap 取 run_action_task / run_image_task 注入 app.state
+executor = ActionTaskExecutor()
+run_action_task = executor.run_action_task
+image_executor = ImageTaskExecutor()
+run_image_task = image_executor.run_image_task
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/interface.py b/backend/packages/app/src/windup_app/server/orchestrator/interface.py
index 83e38aa5..4d0a1d57 100644
--- a/backend/packages/app/src/windup_app/server/orchestrator/interface.py
+++ b/backend/packages/app/src/windup_app/server/orchestrator/interface.py
@@ -22,6 +22,8 @@
from abc import ABC, abstractmethod
+from sqlalchemy.orm import Session
+
from windup_app.server.orchestrator.model import (
CharacterActionInput,
CharacterImageInput,
@@ -35,7 +37,10 @@ class GenerationService(ABC):
# -- 任务提交 ------------------------------------------------------------
@abstractmethod
- def generate_character_image(self, input: CharacterImageInput) -> GenerationTask:
+ def generate_character_image(
+ self, session: Session, *, user_id: int,
+ project_id: int | None, input: CharacterImageInput,
+ ) -> GenerationTask:
"""提交角色图片生成任务。
入参包含参考图 URL 和 prompt 等参数;出参为 ``CharacterImageOutput``,
@@ -43,7 +48,10 @@ def generate_character_image(self, input: CharacterImageInput) -> GenerationTask
"""
@abstractmethod
- def generate_character_action(self, input: CharacterActionInput) -> GenerationTask:
+ def generate_character_action(
+ self, session: Session, *, user_id: int,
+ project_id: int | None, input: CharacterActionInput,
+ ) -> GenerationTask:
"""提交角色动作生成任务。
入参包含角色 ID、动作类型和参考素材;出参为 ``CharacterActionOutput``,
@@ -53,7 +61,9 @@ def generate_character_action(self, input: CharacterActionInput) -> GenerationTa
# -- 查询 ----------------------------------------------------------------
@abstractmethod
- def get_task(self, project_id: int, task_id: int) -> GenerationTask | None:
+ def get_task(
+ self, session: Session, project_id: int, task_id: int,
+ ) -> GenerationTask | None:
"""查询任务状态与结果。
返回完整的 ``GenerationTask``,前端根据 ``status`` 判断是否完成,
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py
index 36fdab58..419b0f8e 100644
--- a/backend/packages/app/src/windup_app/server/orchestrator/model.py
+++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py
@@ -9,6 +9,12 @@
from datetime import datetime, timezone
from enum import StrEnum
+from sqlalchemy import BigInteger, DateTime, Integer, JSON, Text
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column
+
+from windup_framework.db import Base
+
# -- 枚举 ----------------------------------------------------------------
@@ -25,6 +31,7 @@ class ActionType(StrEnum):
WALK = "walk"
IDLE = "idle"
+ JUMP = "jump"
ATTACK = "attack"
CUSTOM = "custom"
@@ -63,6 +70,24 @@ class CharacterActionInput:
reference_video_url: str | None = None
reference_image_urls: list[str] = field(default_factory=list)
num_frames: int = 16
+ # ── action_type=custom 才用到的两个(#239)──────────────────────────────
+ # 这个动作是否循环播放。``None`` 原样往下传,由编排层兜成一次性:本层替调用方填默认值
+ # 的话,"没给"和"明确给了 False"从这里起就再也分不开了。
+ loop: bool | None = None
+ # 视频模型。``None`` = 用部署配置的默认值(kling-v2-5-turbo)。
+ # 取值域见 executor.ALLOWED_VIDEO_MODELS —— 只开放两个,因为每个模型的入参形状不同
+ # (image_list / input_reference / Fal 队列),全开等于把三套协议适配塞进一个改动。
+ video_model: str | None = None
+ # ── 三渲二(#192)────────────────────────────────────────────────────
+ #
+ # 这次动作属于哪个造型。3D 资产挂在造型一级(#121),没有它就连"按造型定位资产"
+ # 都表达不出来。目前只被三渲二消费;推广成所有动作生成都按造型定位外观是 #253。
+ outfit_id: str | None = None
+ # 该造型的绑骨 3D 模型 URL。**有值 = 这次走三渲二**;None = 照旧走 video_i2v。
+ #
+ # 传 URL 而不是让编排层自己去查:与 reference_image_urls 同一口径 —— 取数在上层
+ # 做完,"这次选了哪条路线"在入参上就可见,不是埋在某个分支里的隐式判断。
+ model_3d_url: str | None = None
# -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------
@@ -124,3 +149,55 @@ class GenerationTask:
@property
def is_terminal(self) -> bool:
return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED)
+
+
+# -- ORM -----------------------------------------------------------------
+
+
+class GenerationTaskRecord(Base):
+ """生成任务持久化记录。
+
+ ``input_payload`` 和 ``result`` 以 JSON 存储;``result_type`` 标识
+ ``result`` 的具体类型,读出后按类型反序列化为对应 dataclass。
+ """
+
+ __tablename__ = "windup_generation_task"
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+ task_type: Mapped[str] = mapped_column(
+ Text, nullable=False,
+ default=GenerationType.CHARACTER_IMAGE.value,
+ )
+ status: Mapped[str] = mapped_column(
+ Text, nullable=False,
+ default=TaskStatus.PENDING.value,
+ )
+ input_payload: Mapped[dict] = mapped_column(
+ JSON().with_variant(JSONB, "postgresql"),
+ nullable=False,
+ default=dict,
+ )
+ result_type: Mapped[str | None] = mapped_column(Text, nullable=True)
+ result: Mapped[dict | None] = mapped_column(
+ JSON().with_variant(JSONB, "postgresql"),
+ nullable=True,
+ )
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+ update_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/recover.py b/backend/packages/app/src/windup_app/server/orchestrator/recover.py
new file mode 100644
index 00000000..83ae8aaf
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/recover.py
@@ -0,0 +1,110 @@
+"""进程重启后对账:有冻结、未终态的生成任务重新入队或失败解冻。
+
+调度本身仍是进程内 ThreadPoolExecutor。队列项会随进程消失,但任务行和
+FROZEN 流水在库里。启动时扫描 PENDING/RUNNING 且仍有开放冻结的任务:
+
+- PENDING:按落库的 ``input_payload`` 再入队(生成尚未开始)
+- RUNNING:视为执行中被打断,标 FAILED 并解冻(避免重复打上游)
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from typing import Any
+
+from sqlalchemy.orm import Session
+
+from windup_app.server.orchestrator import billing, task_repo
+from windup_app.server.orchestrator.model import (
+ ActionType,
+ CharacterActionInput,
+ CharacterImageInput,
+ GenerationTask,
+ GenerationType,
+ TaskStatus,
+)
+
+logger = logging.getLogger("windup.generation.recover")
+
+
+def recover_orphaned_generation_tasks(
+ session: Session,
+ *,
+ dispatcher: Any,
+ run_image_task: Callable[..., Any],
+ run_action_task: Callable[..., Any],
+) -> None:
+ """扫描未结清冻结的开放任务并恢复。调用方负责 commit。"""
+ for task in task_repo.list_by_status(
+ session, (TaskStatus.PENDING, TaskStatus.RUNNING),
+ ):
+ if task.id is None or not billing.has_open_freeze(session, task.id):
+ continue
+ if task.status is TaskStatus.RUNNING:
+ _fail_interrupted(session, task)
+ continue
+ _requeue_pending(session, dispatcher, run_image_task, run_action_task, task)
+
+
+def _fail_interrupted(session: Session, task: GenerationTask) -> None:
+ assert task.id is not None
+ task_repo.update_status(
+ session, task.id, TaskStatus.FAILED,
+ error_message="进程中断,已解冻积分",
+ )
+ billing.release_for_task(session, user_id=task.user_id, task_id=task.id)
+ logger.warning("孤儿 RUNNING 任务已失败解冻 | task_id=%s", task.id)
+
+
+def _requeue_pending(
+ session: Session,
+ dispatcher: Any,
+ run_image_task: Callable[..., Any],
+ run_action_task: Callable[..., Any],
+ task: GenerationTask,
+) -> None:
+ assert task.id is not None
+ payload = task.input_payload or {}
+ try:
+ if task.task_type is GenerationType.CHARACTER_IMAGE:
+ dispatcher.submit(
+ run_image_task, task.id, _image_input(payload), task.project_id,
+ )
+ elif task.task_type is GenerationType.CHARACTER_ACTION:
+ dispatcher.submit(
+ run_action_task, task.id, _action_input(payload), task.project_id,
+ )
+ else:
+ raise ValueError(f"未知任务类型: {task.task_type}")
+ except Exception:
+ logger.exception("PENDING 任务重入队失败,改为解冻 | task_id=%s", task.id)
+ _fail_interrupted(session, task)
+ return
+ logger.info("孤儿 PENDING 任务已重入队 | task_id=%s", task.id)
+
+
+def _image_input(payload: dict) -> CharacterImageInput:
+ return CharacterImageInput(
+ reference_image_url=payload.get("reference_image_url"),
+ prompt=payload.get("prompt") or "",
+ negative_prompt=payload.get("negative_prompt") or "",
+ width=int(payload.get("width") or 1024),
+ height=int(payload.get("height") or 1024),
+ num_images=int(payload.get("num_images") or 1),
+ )
+
+
+def _action_input(payload: dict) -> CharacterActionInput:
+ raw_type = payload.get("action_type")
+ action_type = raw_type if isinstance(raw_type, ActionType) else ActionType(raw_type)
+ return CharacterActionInput(
+ character_id=int(payload["character_id"]),
+ action_type=action_type,
+ custom_prompt=payload.get("custom_prompt"),
+ reference_video_url=payload.get("reference_video_url"),
+ reference_image_urls=list(payload.get("reference_image_urls") or []),
+ num_frames=int(payload.get("num_frames") or 16),
+ loop=payload.get("loop"),
+ video_model=payload.get("video_model"),
+ )
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py
new file mode 100644
index 00000000..da9f998d
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_assets.py
@@ -0,0 +1,348 @@
+"""角色级 3D 资产的建造与落点 —— 三渲二里**花钱的那两段**。
+
+ 母版图 bytes ──①图生 3D──▶ 3D 模型 ──(人工确认)──▶ ②自动绑骨 ──▶ 绑骨模型 bytes
+ │
+ 存进 CharacterAssetStore
+ │
+ server 下次直接取出来喂 CharacterGeneratorPort.generate_rendered
+
+**渲帧那一段不在这里**,它在 ai_engine 的 ``RenderFrameStrategy`` 里(纯本地、零 API
+成本)。花钱的留 server、只管渲的留引擎 —— 捆在一个 port 后面就看不出哪一步花钱。
+
+━━ 为什么要有 CharacterAssetStore ━━
+
+三段的成本结构完全不同:①图生 3D 与 ②自动绑骨按积分计费、**每造型一次性**,③渲帧
+零 API、每动作每朝向都免费。没有落点,①② 就得每个动作重跑一次 —— 一个造型做 10 个
+动作,成本差一个数量级(Refs 1024XEngineer/Windup#121)。所以这个 store 不是"存得
+整齐一点",它是这条路线成本优势能否成立的开关。
+
+━━ 键取造型 id ━━
+
+**键是造型(outfit)的稳定 id,不是角色 id、也不是 ``CharacterCard`` 上的任何字段。**
+
+- 挂造型一级(#121):外观挂在造型上(每个 outfit 自带 ``preview_url``),角色级只有一张
+ 参考图,同一角色的不同造型共用不了一个 3D 模型。
+- 由调用方显式传入,不从 card 反查:card 由 executor 现搭,只有 name 和 desc 是可靠的,
+ 拿它上面别的字段当键会恒为 None —— 而单元测试直接构造 card,照样全绿。
+- 不用 ``name``:它不唯一(落库时甚至可以为 null,#123),拿它当键会让两个同名角色互相
+ 复用彼此的模型 —— "看起来省钱、实际出错角色"的静默错误。
+
+━━ 生成出来的 3D 模型要先给人看过才往下走 ━━
+
+①② 之间有一道**人工确认停点**(:class:`ModelReviewGate`)。模型不可事后修改,坏模型
+只能重生成;一口气冲到绑骨+出帧的话,一个坏模型会连带浪费绑骨的积分和后面所有出帧,
+而人要看完一整套序列帧才发现锅在最上游。停点放在图生 3D 之后、绑骨之前,是信息最全
+而花费最少的位置。待审期间 ① 的产物**单独存一份**,故反复调用不会重付那笔钱。
+"""
+from __future__ import annotations
+
+import hashlib
+import logging
+import pathlib
+from enum import Enum
+from typing import TYPE_CHECKING, Protocol, runtime_checkable
+
+from windup_ai_engine.ports import ProgressPort
+from windup_framework.providers.render3d.tencent import (
+ CREDIT_PRICE_CNY,
+ CREDITS,
+ RIG_CREDITS,
+)
+
+if TYPE_CHECKING:
+ from windup_framework.providers.render3d import (
+ AutoRigProvider,
+ Model3DProvider,
+ RiggedModel,
+ )
+
+logger = logging.getLogger(__name__)
+
+# ① 出了模型但还没绑骨的产物,存在同一个 store 里的这个键前缀下。**别在别处再写一遍
+# 字面量** —— 待审模型"在哪"这件事有两个说法时,放行与展示会指向不同的文件。
+RAW_KEY_PREFIX = "raw:"
+
+# 两段的报价。**不在这里抄数字**,从计费实现取 —— 抄一份过去,供应商调价时两处会分叉,
+# 而分叉的那一份正是给用户看的成本提示(告知了错的价钱比不告知更糟)。
+# ``CREDITS["Normal"]`` 是本管线用的生成模式(非 PBR、单视图),与 ``TencentModel3DProvider``
+# 的默认档一致。
+MODEL3D_CREDITS = CREDITS["Normal"]
+AUTORIG_CREDITS = RIG_CREDITS
+BUILD_CREDITS = MODEL3D_CREDITS + AUTORIG_CREDITS
+BUILD_CNY = round(BUILD_CREDITS * CREDIT_PRICE_CNY, 2)
+
+
+class Render3DAssetState(str, Enum):
+ """一个造型的 3D 资产处在哪一步。**状态由落点推出来,不单独存一份** ——
+ 存第二份就有第二个真相,而这两者不同步时用户看到的是"已就绪"、渲帧拿到的是空。
+ """
+
+ ABSENT = "absent" # 什么都没有,点"建"会开始花钱
+ AWAITING_REVIEW = "awaiting_review" # ① 已出模型,卡在人工确认闸上
+ READY = "ready" # ② 已绑骨,渲帧可直接用
+
+
+@runtime_checkable
+class CharacterAssetStore(Protocol):
+ """角色级派生资产(绑好骨的 3D 模型)的落点。
+
+ **必须是跨进程持久的** —— 进程内缓存等于每次重启都重付一遍 ①②,而那正是本文件
+ 开头那笔一个数量级的差价。
+ """
+
+ def get(self, key: str) -> bytes | None: ...
+
+ def put(self, key: str, data: bytes) -> None: ...
+
+ def delete(self, key: str) -> None:
+ """删掉一份产物。给"模型不合格、重新生成"用 —— 不删的话下次调用会把同一个坏
+ 模型再交一遍给人审,重生成的入口就成了死键。"""
+ ...
+
+
+class LocalDirAssetStore(CharacterAssetStore):
+ """落在本地目录的实现。
+
+ **部署注意:这个目录必须挂持久卷。** 落在容器可写层里的话,每次重建镜像/重启都会
+ 清空,于是角色级资产退化成"每次部署后第一个动作重付 ①②"。要在多副本后端上用,
+ 应换成对象存储实现(同一个 Protocol,换注入即可)—— 那一步等 #121 拍板后做。
+ """
+
+ def __init__(self, root: pathlib.Path) -> None:
+ self._root = root
+ self._root.mkdir(parents=True, exist_ok=True)
+
+ def _path(self, key: str) -> pathlib.Path:
+ # key 里可能有 URL / 路径分隔符,哈希成扁平文件名;保留前缀便于人肉排查。
+ digest = hashlib.sha256(key.encode()).hexdigest()[:32]
+ return self._root / f"rigged_{digest}.bin"
+
+ def get(self, key: str) -> bytes | None:
+ p = self._path(key)
+ return p.read_bytes() if p.is_file() else None
+
+ def put(self, key: str, data: bytes) -> None:
+ # 先写临时文件再 rename:半截文件被当成"资产已就绪"会让下一次渲染拿到坏模型,
+ # 而那时钱已经花完,错误却显形在出帧台("Bad glTF"),排查方向整个跑偏。
+ p = self._path(key)
+ tmp = p.with_suffix(".part")
+ tmp.write_bytes(data)
+ tmp.replace(p)
+
+ def delete(self, key: str) -> None:
+ self._path(key).unlink(missing_ok=True)
+
+
+class SpendNotAuthorized(ValueError):
+ """要花钱建资产,但本部署没打开花钱开关。
+
+ 单拎一个类型是给上层用的:它与"造型 id 缺失""母版拉不到"这些同样抛 ValueError 的
+ 输入问题修法完全不同(前者改部署配置,后者改请求),压成一种就只能靠比对消息文本
+ 分支 —— 而消息会改。继承 ValueError 让既有的 ``pytest.raises(ValueError)`` 仍然成立。
+ """
+
+
+class ModelAwaitingReview(RuntimeError):
+ """3D 模型已生成、**在等人看过点头**,还不能往下走。
+
+ 不是错误,是流程里的一个停点。故消息里带着"去哪看"和"怎么放行",让收到它的人
+ 知道下一步该做什么,而不是以为管线坏了。
+ """
+
+ def __init__(self, key: str, where: str, how: str) -> None:
+ super().__init__(f"3D 模型待人工确认(key={key})。看这里:{where};放行:{how}")
+ self.key = key
+ self.where = where
+
+
+@runtime_checkable
+class ModelReviewGate(Protocol):
+ """生成出来的 3D 模型,**必须先给人看过、点头,才允许往下花钱绑骨 / 出帧**。
+
+ 为什么这一道非要有:混元生成的 3D 模型**没法事后好好修改**,等于"生成即最终" ——
+ 拓扑、绑点、配件都在生成那一步定死。所以模型不合格时唯一的补救是重新生成,而不是
+ 修它。若管线一口气从图生 3D 冲到绑骨+出帧,一个坏模型会连带浪费掉绑骨那笔计费和
+ 后面所有出帧,人还要看完一整套序列帧才发现问题出在最上游那一步。
+
+ 把停点放在图生 3D **之后、绑骨之前**,是因为这里是信息最全而花费最少的位置:
+ 模型已经在手上可以旋转着看,而下游的钱一分还没花。
+ """
+
+ def submit(self, key: str, model: bytes, fmt: str) -> str:
+ """把待审模型交出去,返回"人该去哪看"的位置说明。"""
+ ...
+
+ def is_approved(self, key: str) -> bool:
+ """人是否已点头。**不得自动变 True** —— 那就等于这道闸不存在。"""
+ ...
+
+ def approve(self, key: str) -> None:
+ """人看过并点头。**只允许由人的显式操作触达**(CLI、或前端那个"通过"按钮),
+ 管线自身任何一条路径都不得调它 —— 会自己点头的闸就是没有闸。"""
+ ...
+
+ def discard(self, key: str) -> None:
+ """人看过并否掉:丢弃待审模型。混元的模型改不动,不合格只能重生成,
+ 所以否掉必须真的把它删了 —— 留着的话下次调用会把同一个坏模型再交一遍。"""
+ ...
+
+
+class LocalDirModelReview(ModelReviewGate):
+ """落本地目录 + 一个批准标记文件。
+
+ 放行方式刻意做成"人手动建一个标记文件",而不是任何形式的超时自动放行:
+ 自动放行的闸等于没有闸,只是把"没人看"伪装成"看过了"。
+ """
+
+ def __init__(self, root: pathlib.Path) -> None:
+ self._root = root
+ self._root.mkdir(parents=True, exist_ok=True)
+
+ def _stem(self, key: str) -> pathlib.Path:
+ return self._root / hashlib.sha256(key.encode()).hexdigest()[:32]
+
+ def submit(self, key: str, model: bytes, fmt: str) -> str:
+ model_path = self._stem(key).with_suffix(f".{fmt.lower()}")
+ if not model_path.is_file(): # 已交过就别重写,人可能正在看
+ tmp = model_path.with_suffix(".part")
+ tmp.write_bytes(model)
+ tmp.replace(model_path)
+ (self._stem(key).with_suffix(".key.txt")).write_text(key, encoding="utf-8")
+ return str(model_path)
+
+ def is_approved(self, key: str) -> bool:
+ return self._stem(key).with_suffix(".approved").is_file()
+
+ def approve(self, key: str) -> None:
+ """人看过之后放行(给 CLI / 运维 / 前端那个"通过"按钮用;管线自己**不会**调这个)。"""
+ self._stem(key).with_suffix(".approved").write_text("ok", encoding="utf-8")
+
+ def discard(self, key: str) -> None:
+ """否掉待审模型。连批准标记一起删:留着标记而删了模型,下次生成出来的新模型
+ 会被这枚旧标记直接放行,人一眼都没看到就进了绑骨。"""
+ stem = self._stem(key)
+ for path in self._root.glob(f"{stem.name}.*"):
+ path.unlink(missing_ok=True)
+
+
+class Render3DAssetBuilder:
+ """把①图生 3D + ②自动绑骨拼成"母版 → 该造型的绑骨模型",并落点复用。
+
+ **本类不渲帧。** 渲帧在 ai_engine 的 ``RenderFrameStrategy``(零 API 成本)。
+ """
+
+ def __init__(
+ self,
+ model3d: Model3DProvider,
+ autorig: AutoRigProvider,
+ store: CharacterAssetStore,
+ review: ModelReviewGate,
+ *,
+ may_build_assets: bool = False,
+ ) -> None:
+ self._model3d = model3d
+ self._autorig = autorig
+ self._store = store
+ self._review = review
+ self._may_build_assets = may_build_assets
+
+ @property
+ def may_build_assets(self) -> bool:
+ """本实例获准花钱建资产没有。给上层**在起后台任务之前**问 —— 起了再失败的话,
+ 用户看到的是"建到一半炸了",而事实是这台机器根本没打算建。"""
+ return self._may_build_assets
+
+ def get(self, outfit_key: str) -> bytes | None:
+ """已就绪的绑骨模型;``None`` = 还没有。**不花钱、无副作用。**
+
+ 这是 server 决定"这次调 generate 还是 generate_rendered"时用的那个判断
+ (#122:判据由 server 出,不挂在引擎的 port 上)。
+ """
+ return self._store.get(outfit_key) if outfit_key else None
+
+ def state(self, outfit_key: str) -> Render3DAssetState:
+ """该造型走到哪一步了。**不花钱、无副作用**,给状态查询端点用。"""
+ if outfit_key and self._store.get(outfit_key) is not None:
+ return Render3DAssetState.READY
+ if outfit_key and self._store.get(f"{RAW_KEY_PREFIX}{outfit_key}") is not None:
+ return Render3DAssetState.AWAITING_REVIEW
+ return Render3DAssetState.ABSENT
+
+ def approve(self, outfit_key: str) -> None:
+ """人点头放行。**本类不会自己调它** —— 调用点只有面向人的入口(端点 / CLI)。
+
+ 放行本身不绑骨:绑骨是下一次 :meth:`ensure` 的事,那里才有母版和进度回调。
+ """
+ self._review.approve(outfit_key)
+
+ def discard(self, outfit_key: str) -> None:
+ """人否掉待审模型:删待审件,回到 ``ABSENT``,下次 :meth:`ensure` 重新生成。
+
+ **注意这一步的代价**:重新生成要再付一次图生 3D。之所以还是删,
+ 是因为混元的模型改不动(生成即最终),留着一个不合格的模型只有两种下场 ——
+ 要么被误放行进绑骨(再赔一次绑骨计费和之后所有出帧),要么永远卡在闸上。
+ """
+ self._store.delete(f"{RAW_KEY_PREFIX}{outfit_key}")
+ self._review.discard(outfit_key)
+
+ def ensure(self, outfit_key: str, master: bytes, progress: ProgressPort) -> bytes:
+ """取该造型的绑骨模型;没有且获准时才现建。
+
+ 建一次的计费 = 图生 3D + 绑骨,取值见本模块顶部常量,**每造型一次性**。
+ ``may_build_assets=False``(默认)时不建 —— 一个 web 请求不该顺手扣这笔钱,
+ 那正是"无人值守烧钱"。要放开就显式设 ``WINDUP_RENDER3D_ALLOW_SPEND``。
+ """
+ if not outfit_key:
+ raise ValueError(
+ "缺少造型 id,无法定位/复用该造型的 3D 资产。继续跑会让图生 3D + 绑骨"
+ "按动作重复计费(每造型一次性 → 每动作一次),故在花钱之前停下。"
+ )
+ rigged_bytes = self._store.get(outfit_key)
+ if rigged_bytes is not None:
+ return rigged_bytes
+ if not self._may_build_assets:
+ raise SpendNotAuthorized(
+ f"造型 {outfit_key!r} 的 3D 资产未就绪,而本实例未获准建(建一次 "
+ f"{BUILD_CREDITS} 积分,约 ¥{BUILD_CNY}:图生 3D {MODEL3D_CREDITS} + "
+ f"绑骨 {AUTORIG_CREDITS})。要现建请显式授权花钱,"
+ "或先把资产备好,或改走 video_i2v。"
+ )
+ return self._build(outfit_key, master, progress)
+
+ # ── 内部 ─────────────────────────────────────────────────────────────
+ def _build(self, key: str, master: bytes, progress: ProgressPort) -> bytes:
+ """① 图生 3D →(人工确认)→ ② 绑骨。**按次计费,每造型一次性。**
+
+ 中间那道人工确认是硬停点,原因见 :class:`ModelReviewGate`:模型不可事后修改,
+ 坏模型只能重生成,所以要在**花绑骨的钱之前**让人看一眼。
+ """
+ raw_key = f"{RAW_KEY_PREFIX}{key}"
+
+ # 图生 3D 的产物单独存一份。**这不是冗余** —— 待审期间会有第二次、第三次调用走到
+ # 这里,若不存,每次都要重付一遍图生 3D 的钱,而停点的本意恰恰是省钱。
+ model = self._store.get(raw_key)
+ if model is None:
+ progress.step("assets", 0, 2, "造型级 3D 资产未就绪:图生 3D(按次计费)")
+ model = self._model3d.image_to_3d(master, want="GLB")
+ self._store.put(raw_key, model)
+ logger.info("图生 3D 产物已落点 key=%s bytes=%d", raw_key, len(model))
+
+ where = self._review.submit(key, model, "GLB")
+ if not self._review.is_approved(key):
+ progress.step("assets", 1, 2, "3D 模型已生成,等人工确认后才继续绑骨")
+ raise ModelAwaitingReview(
+ key,
+ where,
+ "旋转着看:把待审的 .glb 放到一个静态服务下用 three.js 的 GLTFLoader "
+ "+ OrbitControls 开(浏览器禁止 file:// 加载本地模型,必须走 http://localhost);"
+ "确认可用就在同目录建一个同名 .approved 空文件放行;"
+ "不合格则删掉待审模型重新生成 —— 混元的模型改不动,只能重生成",
+ )
+
+ progress.step("assets", 1, 2, "模型已确认,自动绑骨(按次计费)")
+ rigged: RiggedModel = self._autorig.rig(model, want="GLB")
+
+ # 存的是**绑骨后**的产物:它是渲帧真正要的那个,存中间的 model 等于下次还得再绑一次。
+ self._store.put(key, rigged.data)
+ logger.info("造型级 3D 资产已落点 key=%s fmt=%s", key, rigged.fmt)
+ return rigged.data
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py b/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py
new file mode 100644
index 00000000..60fc17ad
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/render3d_service.py
@@ -0,0 +1,392 @@
+"""造型级 3D 资产的**面向人的**那一层:母版预检 + 建资产流程的状态与放行。
+
+``render3d_assets.Render3DAssetBuilder`` 只认 bytes、只做"没有就建",它不知道母版在
+哪张表的哪个字段上、也没有可以给人点的按钮。本模块补上这两件,并把两段付费调用挪出
+请求线程 —— 图生 3D 与绑骨各要几十秒到几分钟,同步跑等于让浏览器挂在那儿等超时。
+
+**分层**:本模块经 builder 牵出 ai_engine,故 web/worker **不得 import 本模块**
+(会违反"入口层不经 ai_engine 直连"的门禁,该契约是传递性的)。装配方式与
+``executor`` 一样:bootstrap import + 注入 ``app.state``,web 端运行期取回。
+出参因此一律是 JSON-ready 的 ``dict`` —— 让 web 层为了标注类型去 import 本模块,
+等于把刚才那条门禁绕过去。
+
+━━ 为什么母版这道闸比模型那道更值钱 ━━
+
+重出一张母版比重建一次 3D 便宜一个量级。母版不合格 → 模型必然不合格,而混元的模型
+**生成即最终**(拓扑、绑点在生成那一步定死,事后改不动),只能重来。所以同一个错误,
+在模型处纠正的代价是母版处的数倍,还要叠加已经花掉的那次母版。闸放在最便宜的位置。
+"""
+from __future__ import annotations
+
+import logging
+import os
+import pathlib
+import threading
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from windup_ai_engine.master_check import check_master
+from windup_ai_engine.ports import MasterRejected
+
+from windup_app.server.orchestrator._fetch import FetchNotAllowed, fetch_own_media
+from windup_app.server.orchestrator.render3d_assets import (
+ AUTORIG_CREDITS,
+ BUILD_CNY,
+ BUILD_CREDITS,
+ MODEL3D_CREDITS,
+ RAW_KEY_PREFIX,
+ CharacterAssetStore,
+ LocalDirAssetStore,
+ LocalDirModelReview,
+ ModelAwaitingReview,
+ Render3DAssetBuilder,
+ Render3DAssetState,
+ SpendNotAuthorized,
+)
+
+logger = logging.getLogger("windup.render3d.service")
+
+__all__ = [
+ "PHASE_BUILDING", "PHASE_FAILED", "PHASE_RIGGING",
+ "FetchNotAllowed", "MasterPrecheckFailed", "Render3DAssetOperations",
+ "SpendNotAuthorized", "default_operations", "precheck_master",
+ "precheck_master_bytes",
+]
+
+# 落点里存"绑骨模型的公网 URL"用的键前缀。与模型 bytes 同一个 store,是因为两者的
+# 存活期必须一致:模型还在而 URL 丢了,前端会一直显示"没有 3D 模型",而钱已经花完。
+_URL_PREFIX = "url:"
+
+# 只存在于内存里的两个过渡态。进程重启会丢 —— 丢了也只是退回按落点推出来的真状态
+# (absent / awaiting_review),不会撒谎说"已就绪"。
+PHASE_BUILDING = "building"
+PHASE_RIGGING = "rigging"
+PHASE_FAILED = "failed"
+
+
+def precheck_master_bytes(master: bytes, canvas: tuple[int, int] | None = None) -> dict:
+ """对一张母版跑零成本预检,把结果翻成 JSON。**不抛业务异常,拒绝也是一种结果。**
+
+ 拒绝之所以走返回值而不是异常:第一个调用方是母版确认闸上的展示,"这张不能用"
+ 正是它要显示的东西。真正需要拦住的地方(建资产入口)自己看 ``accepted``,
+ 见 :meth:`Render3DAssetOperations.build`。
+ """
+ try:
+ facts = check_master(master, canvas)
+ except MasterRejected as exc:
+ return {"accepted": False, "reject_code": exc.code.value, "detail": exc.detail,
+ "facts": None, "warnings": []}
+ return {
+ "accepted": True,
+ "reject_code": None,
+ "detail": facts.note(),
+ "facts": {
+ "width": facts.size[0],
+ "height": facts.size[1],
+ "subject_ratio": round(facts.subject_ratio, 4),
+ "subject_area_ratio": round(facts.subject_area_ratio, 6),
+ "limb_segments": list(facts.limb_segments),
+ "components": list(facts.components),
+ },
+ "warnings": [{"code": w.code.value, "detail": w.detail} for w in facts.warnings],
+ }
+
+
+def precheck_master(master_url: str, canvas: tuple[int, int] | None = None) -> dict:
+ """:func:`precheck_master_bytes` 的 URL 版。取图受限于自家对象存储,见 ``_fetch``。"""
+ return precheck_master_bytes(fetch_own_media(master_url), canvas)
+
+
+class MasterPrecheckFailed(ValueError):
+ """母版没过预检,拒绝为它花钱建 3D。``report`` 原样带给上层做文案。"""
+
+ def __init__(self, report: dict) -> None:
+ super().__init__(f"母版未通过预检({report['reject_code']}):{report['detail']}")
+ self.report = report
+
+
+@dataclass
+class _Job:
+ """一个造型正在进行中的那段付费调用。``error`` 非空即 :data:`PHASE_FAILED`。"""
+
+ phase: str
+ error: str | None = None
+
+
+def _spawn_thread(work: Callable[[], None]) -> None:
+ threading.Thread(target=work, daemon=True).start()
+
+
+class Render3DAssetOperations:
+ """建资产流程里**人能触达的四个动作**:看状态、建、放行、否掉。
+
+ ``spawn`` 可注入:缺省起后台线程,测试里换成"就地跑完",这样用例不必等线程收敛
+ (等线程的用例会变成偶发失败,而偶发失败最后都会被人当噪音忽略)。
+ """
+
+ def __init__(
+ self,
+ builder: Render3DAssetBuilder,
+ store: CharacterAssetStore,
+ publish: Callable[[bytes], str],
+ *,
+ fetch: Callable[[str], bytes] = fetch_own_media,
+ spawn: Callable[[Callable[[], None]], None] = _spawn_thread,
+ ) -> None:
+ self._builder = builder
+ self._store = store
+ self._publish = publish
+ self._fetch = fetch
+ self._spawn = spawn
+ self._jobs: dict[str, _Job] = {}
+ self._lock = threading.Lock()
+
+ # ── 查 ───────────────────────────────────────────────────────────────
+ def view(self, outfit_key: str) -> dict:
+ """状态 + 成本。**不花钱、无副作用**,可以随便轮询。
+
+ 成本恒在返回里,不只在"还没建"的时候给:前端拿它渲染扣费提示,
+ 而按次计费的触发点绝不能出现在用户不知情的时候。
+ """
+ state = self._builder.state(outfit_key)
+ with self._lock:
+ job = self._jobs.get(outfit_key)
+ phase = state.value
+ error = None
+ if job is not None and state is not Render3DAssetState.READY:
+ phase, error = job.phase, job.error
+ rigged_url = self._store.get(f"{_URL_PREFIX}{outfit_key}")
+ review_url = self._store.get(f"{_URL_PREFIX}{RAW_KEY_PREFIX}{outfit_key}")
+ return {
+ "asset_key": outfit_key,
+ "state": phase,
+ "model_3d_url": rigged_url.decode() if rigged_url else None,
+ "review_model_url": review_url.decode() if review_url else None,
+ "error": error,
+ "cost": {
+ "model3d_credits": MODEL3D_CREDITS,
+ "autorig_credits": AUTORIG_CREDITS,
+ "total_credits": BUILD_CREDITS,
+ "total_cny": BUILD_CNY,
+ "billing": "postpaid",
+ "scope": "per_outfit_once",
+ },
+ }
+
+ # ── 三个动作 ─────────────────────────────────────────────────────────
+ def build(self, outfit_key: str, master_url: str) -> dict:
+ """① 图生 3D。**这一步开始花钱**,所以只在两个前提都成立时才起:
+ 该造型确实还什么都没有,且母版过得了零成本预检。
+
+ 预检不过就在这里拒:母版不合格 → 模型必然不合格,而模型改不动只能重生成。
+ 警告不拦 —— 它们已经在母版确认闸上给人看过,人点了"就用这张"就是他的决定。
+ """
+ if not self._builder.may_build_assets:
+ raise SpendNotAuthorized(
+ f"本部署未开启建 3D 资产(需 WINDUP_RENDER3D_ALLOW_SPEND)。建一次 "
+ f"{BUILD_CREDITS} 积分,约 ¥{BUILD_CNY}。"
+ )
+ state = self._builder.state(outfit_key)
+ if state is not Render3DAssetState.ABSENT:
+ raise ValueError(f"造型 {outfit_key!r} 的 3D 资产已处于 {state.value},不能重复建")
+ with self._lock:
+ if outfit_key in self._jobs and self._jobs[outfit_key].phase != PHASE_FAILED:
+ raise ValueError(f"造型 {outfit_key!r} 的 3D 资产正在建,别重复提交")
+
+ master = self._fetch(master_url)
+ report = precheck_master_bytes(master)
+ if not report["accepted"]:
+ raise MasterPrecheckFailed(report)
+
+ self._start(outfit_key, PHASE_BUILDING, master)
+ return self.view(outfit_key)
+
+ def approve(self, outfit_key: str, master_url: str) -> dict:
+ """人点头 → ② 绑骨。**这道闸不会自己点头**,只有本方法能放行,而它只挂在
+ 面向人的端点上。"""
+ state = self._builder.state(outfit_key)
+ if state is not Render3DAssetState.AWAITING_REVIEW:
+ raise ValueError(f"造型 {outfit_key!r} 处于 {state.value},没有待审模型可放行")
+ self._builder.approve(outfit_key)
+ self._start(outfit_key, PHASE_RIGGING, self._fetch(master_url))
+ return self.view(outfit_key)
+
+ def discard(self, outfit_key: str) -> dict:
+ """人否掉待审模型 → 回到 ``absent``,下次建会重新生成(再付一次图生 3D)。"""
+ state = self._builder.state(outfit_key)
+ if state is not Render3DAssetState.AWAITING_REVIEW:
+ raise ValueError(f"造型 {outfit_key!r} 处于 {state.value},没有待审模型可否掉")
+ self._builder.discard(outfit_key)
+ self._store.delete(f"{_URL_PREFIX}{RAW_KEY_PREFIX}{outfit_key}")
+ with self._lock:
+ self._jobs.pop(outfit_key, None)
+ return self.view(outfit_key)
+
+ # ── 内部 ─────────────────────────────────────────────────────────────
+ def _start(self, outfit_key: str, phase: str, master: bytes) -> None:
+ with self._lock:
+ self._jobs[outfit_key] = _Job(phase)
+ self._spawn(lambda: self._run(outfit_key, master))
+
+ def _publish_for_review(self, outfit_key: str) -> None:
+ """把待审模型也放到对象存储上。
+
+ **不放的话这道闸没法用**:模型只躺在服务器磁盘上,人点"通过"时其实一眼都没看到,
+ 于是闸退化成一个必须点的按钮 —— 比没有闸更糟,它制造了"已经审过"的假象。
+ """
+ url_key = f"{_URL_PREFIX}{RAW_KEY_PREFIX}{outfit_key}"
+ if self._store.get(url_key) is not None:
+ return
+ model = self._store.get(f"{RAW_KEY_PREFIX}{outfit_key}")
+ if model is None:
+ return
+ try:
+ self._store.put(url_key, self._publish(model).encode())
+ except Exception: # noqa: BLE001 - 看不了不等于建失败
+ logger.exception("[WINDUP] 待审模型上传失败 | outfit=%s", outfit_key)
+
+ def _run(self, outfit_key: str, master: bytes) -> None:
+ """两段付费调用共用这一条:``ensure`` 自己知道该走 ① 还是 ②。
+
+ ``ModelAwaitingReview`` 不是失败,是 ① 干完了、停在闸上 —— 把它当失败会让
+ 用户看到一条红色报错,而实际上该看到的是"去看模型"。
+ """
+ try:
+ rigged = self._builder.ensure(outfit_key, master, _SilentProgress())
+ except ModelAwaitingReview:
+ self._publish_for_review(outfit_key)
+ with self._lock:
+ self._jobs.pop(outfit_key, None)
+ return
+ except Exception as exc: # noqa: BLE001 - 后台线程兜底
+ logger.exception("[WINDUP] 建 3D 资产失败 | outfit=%s", outfit_key)
+ with self._lock:
+ self._jobs[outfit_key] = _Job(PHASE_FAILED, f"{type(exc).__name__}: {exc}")
+ return
+ try:
+ url = self._publish(rigged)
+ except Exception as exc: # noqa: BLE001 - 同上
+ logger.exception("[WINDUP] 绑骨模型上传失败 | outfit=%s", outfit_key)
+ with self._lock:
+ self._jobs[outfit_key] = _Job(
+ PHASE_FAILED,
+ f"绑骨已完成(积分已扣)但上传失败,重试不会重新扣费:{exc}",
+ )
+ return
+ self._store.put(f"{_URL_PREFIX}{outfit_key}", url.encode())
+ with self._lock:
+ self._jobs.pop(outfit_key, None)
+
+
+class _SilentProgress:
+ """建资产跑在后台线程上,没有 SSE 连接可推;进度落日志。"""
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ logger.info("[WINDUP] render3d %s %d/%d %s", stage, i, total, note)
+
+
+# ── 真实装配 ────────────────────────────────────────────────────────────────
+
+
+class _LazyModel3D:
+ """腾讯云凭证在**真要花钱的那一刻**才解析。
+
+ 装配期解析的后果是:没配三渲二凭证的部署整个起不来,而那些部署里绝大多数请求
+ 根本不走这条路线 —— 一条没人用的路线不该有权否决整个服务的启动。
+ """
+
+ def __init__(self, allow_spend: bool) -> None:
+ self._allow_spend = allow_spend
+
+ def image_to_3d(self, master: bytes, *, want: str = "GLB") -> bytes:
+ from windup_framework.providers.render3d import TencentModel3DProvider
+
+ return TencentModel3DProvider(allow_spend=self._allow_spend).image_to_3d(
+ master, want=want
+ )
+
+
+class _LazyAutoRig:
+ def __init__(self, allow_spend: bool) -> None:
+ self._allow_spend = allow_spend
+
+ def rig(self, model: bytes, *, want: str = "GLB", motion=None):
+ from windup_framework.providers.render3d import (
+ TencentAutoRigProvider,
+ TencentCosModelUploader,
+ )
+
+ provider = TencentAutoRigProvider(
+ TencentCosModelUploader(), allow_spend=self._allow_spend
+ )
+ return provider.rig(model, want=want, motion=motion)
+
+
+def _publish_model(data: bytes) -> str:
+ """把绑骨模型放到对象存储,拿到 ``outfits[].model_3d_url`` 要的那个 URL。"""
+ from windup_app.server.media.model import MediaUploadInput
+ from windup_app.server.media.service import service as media_service
+
+ return media_service.upload(
+ data,
+ MediaUploadInput(
+ filename="rigged.glb",
+ content_type="model/gltf-binary",
+ size=len(data),
+ category="model-3d",
+ ),
+ ).url
+
+
+def _assemble() -> Render3DAssetOperations:
+ """线上装配。``WINDUP_RENDER3D_ALLOW_SPEND`` 关着时端点照常在,只是点"建"会明说
+ 本部署不许花钱 —— 比不装好:不装的话前端连状态都读不到,用户看到的是坏页面
+ 而不是"这台机器没开这个功能"。
+
+ 落点目录**必须挂持久卷**,理由见 ``LocalDirAssetStore``。
+ """
+ root = pathlib.Path(os.getenv("WINDUP_RENDER3D_ASSET_DIR") or "var/render3d")
+ allow_spend = os.getenv("WINDUP_RENDER3D_ALLOW_SPEND", "").strip().lower() in {
+ "1", "true", "yes", "on",
+ }
+ store = LocalDirAssetStore(root / "assets")
+ builder = Render3DAssetBuilder(
+ model3d=_LazyModel3D(allow_spend),
+ autorig=_LazyAutoRig(allow_spend),
+ store=store,
+ review=LocalDirModelReview(root / "review"),
+ may_build_assets=allow_spend,
+ )
+ return Render3DAssetOperations(builder, store, _publish_model)
+
+
+class _LazyOperations:
+ """真有人调这些端点时才装配。
+
+ 装配会建落点目录,而 ``create_app()`` 每跑一次就装配一次 —— 包括每个测试用例。
+ 在工作目录里落一堆空目录不是功能,是副作用。
+ """
+
+ def __init__(self) -> None:
+ self._inner: Render3DAssetOperations | None = None
+
+ def _ops(self) -> Render3DAssetOperations:
+ if self._inner is None:
+ self._inner = _assemble()
+ return self._inner
+
+ def view(self, outfit_key: str) -> dict:
+ return self._ops().view(outfit_key)
+
+ def build(self, outfit_key: str, master_url: str) -> dict:
+ return self._ops().build(outfit_key, master_url)
+
+ def approve(self, outfit_key: str, master_url: str) -> dict:
+ return self._ops().approve(outfit_key, master_url)
+
+ def discard(self, outfit_key: str) -> dict:
+ return self._ops().discard(outfit_key)
+
+
+def default_operations() -> _LazyOperations:
+ return _LazyOperations()
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/service.py b/backend/packages/app/src/windup_app/server/orchestrator/service.py
new file mode 100644
index 00000000..d496eec8
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/service.py
@@ -0,0 +1,64 @@
+"""生成任务领域服务(提交 + 查询)。
+
+:class:`AiGenerationService` 负责**建任务记录、预付费冻结、查任务**——web 层依赖本模块。
+实际 AI 生成(调 ai_engine)在 :mod:`.executor` 后台跑,本模块**不碰 ai_engine**,
+以满足"入口层(web/worker)不经 ai_engine 直连"的分层门禁(web → service 不得牵出 ai_engine)。
+
+无状态:``session`` 由调用方按请求传入,本对象作模块级单例(:data:`service`)。
+"""
+
+from __future__ import annotations
+
+import dataclasses
+
+from sqlalchemy.orm import Session
+
+from windup_app.server.orchestrator import billing, task_repo
+from windup_app.server.orchestrator.interface import GenerationService
+from windup_app.server.orchestrator.model import (
+ CharacterActionInput,
+ CharacterImageInput,
+ GenerationTask,
+ GenerationType,
+)
+
+
+class AiGenerationService(GenerationService):
+ """生成任务服务:提交(建 PENDING 记录)+ 查询。生成执行在 executor 后台。"""
+
+ def generate_character_image(
+ self, session: Session, *, user_id: int, project_id: int | None = None,
+ input: CharacterImageInput,
+ ) -> GenerationTask:
+ task = task_repo.create_task(
+ session, user_id=user_id, project_id=project_id,
+ task_type=GenerationType.CHARACTER_IMAGE,
+ input_payload=dataclasses.asdict(input),
+ )
+ billing.reserve_for_task(
+ session, user_id=user_id, task_id=task.id, task_type=task.task_type,
+ )
+ return task
+
+ def generate_character_action(
+ self, session: Session, *, user_id: int, project_id: int | None = None,
+ input: CharacterActionInput,
+ ) -> GenerationTask:
+ """建动作生成任务(PENDING)并返回;实际生成由 executor 后台跑,前端轮询 get_task。"""
+ task = task_repo.create_task(
+ session, user_id=user_id, project_id=project_id,
+ task_type=GenerationType.CHARACTER_ACTION,
+ input_payload=dataclasses.asdict(input),
+ )
+ billing.reserve_for_task(
+ session, user_id=user_id, task_id=task.id, task_type=task.task_type,
+ )
+ return task
+
+ def get_task(
+ self, session: Session, project_id: int, task_id: int,
+ ) -> GenerationTask | None:
+ return task_repo.get_task(session, task_id)
+
+
+service = AiGenerationService()
diff --git a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py
new file mode 100644
index 00000000..3fcb61fb
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py
@@ -0,0 +1,242 @@
+"""生成任务数据访问层。
+
+纯 CRUD 操作,不含业务逻辑。所有函数接收 ``session: Session``,
+由调用方(FastAPI ``get_session`` 依赖)管理事务边界——本模块只
+``flush`` 不 ``commit``。
+
+状态变更时自动向 EventBus 推送完整 task 数据(若已绑定),
+供 SSE 端点实时推送给前端,替代轮询。
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from windup_app.server.orchestrator.model import (
+ CharacterActionOutput,
+ CharacterImageOutput,
+ GenerationTask,
+ GenerationTaskRecord,
+ GenerationType,
+ TaskStatus,
+)
+
+logger = logging.getLogger("windup.task_repo")
+
+# EventBus 引用(bootstrap 中绑定,避免循环导入)
+_event_bus = None
+
+
+def bind_event_bus(event_bus) -> None:
+ """绑定 EventBus 实例(bootstrap 中调用)。"""
+ global _event_bus
+ _event_bus = event_bus
+
+
+# 状态 → SSE 事件名。终态必须用独立事件名:web 层的 stream 靠事件名判断何时收尾
+# (``_TERMINAL_EVENTS``),一律发 "task_update" 的话那个判断永不成立 —— 客户端收到
+# completed 之后连接仍开着,只能靠心跳挂到超时,而端点带 retry: 3000,浏览器原生
+# EventSource 会每 3 秒重连、每次重收同一条 completed(2026-08-10 机器审逮到)。
+_STATUS_EVENT = {
+ TaskStatus.COMPLETED.value: "completed",
+ TaskStatus.FAILED.value: "failed",
+}
+
+
+def task_event_payload(task: GenerationTask) -> dict:
+ """SSE 事件体。**只有这一份实现**。
+
+ 抽成公开函数是因为有第二个发送点:SSE 端点在订阅时若发现任务已是终态,要立即补发
+ 一条终态事件。那里再抄一份字段列表就是第二个真相源 —— 加字段时漏掉一处,客户端
+ 会拿到形状不一致的两种同名事件。
+ """
+ return {
+ "id": task.id,
+ "user_id": task.user_id,
+ "project_id": task.project_id,
+ "task_type": task.task_type.value,
+ "status": task.status.value,
+ "input_payload": task.input_payload,
+ "result": dataclasses.asdict(task.result) if task.result else None,
+ "error_message": task.error_message,
+ }
+
+
+def terminal_event_for(task: GenerationTask) -> str | None:
+ """任务已处于终态时对应的事件名;非终态返回 None。"""
+ return _STATUS_EVENT.get(task.status.value)
+
+
+def _publish_task_update(task_id: int, task: GenerationTask) -> None:
+ """将完整 task 推送到 EventBus(若有订阅者)。
+
+ EventBus 的键是 ``(project_id, task_id)``(主线 #110:同一 task_id 在不同项目下互不
+ 串流)。所以 ``task.project_id`` 为空时**发不到任何订阅者** —— 订阅方拿的键一定带
+ 着一个真实的 project_id。这种情况记 warning 而不是静默 publish 到一个没人听的键上:
+ 静默发出去的话,现象是"任务确实在跑、状态也在落库,但前端进度条一动不动",
+ 而日志里一行异常都没有。
+ """
+ if _event_bus is None:
+ return
+ if task.project_id is None:
+ logger.warning(
+ "任务 %d 没有 project_id,SSE 事件无法投递(EventBus 按 (project_id, task_id) 索引)",
+ task_id,
+ )
+ return
+ event = _STATUS_EVENT.get(task.status.value, "task_update")
+ _event_bus.publish(task.project_id, task_id, event, task_event_payload(task))
+
+
+# ── 写入 ─────────────────────────────────────────────────────────────────
+
+
+def create_task(
+ session: Session,
+ *,
+ user_id: int,
+ project_id: int | None,
+ task_type: GenerationType,
+ input_payload: dict,
+) -> GenerationTask:
+ """创建生成任务记录,返回领域对象。"""
+ record = GenerationTaskRecord(
+ user_id=user_id,
+ project_id=project_id,
+ task_type=task_type.value,
+ status=TaskStatus.PENDING.value,
+ input_payload=input_payload,
+ )
+ session.add(record)
+ session.flush()
+ return _record_to_domain(record)
+
+
+def update_status(
+ session: Session,
+ task_id: int,
+ status: TaskStatus,
+ *,
+ error_message: str | None = None,
+) -> None:
+ """更新任务状态(可选附带错误信息)。"""
+ record = session.get(GenerationTaskRecord, task_id)
+ if record is None:
+ return
+ record.status = status.value
+ record.error_message = error_message
+ record.update_at = datetime.now(timezone.utc)
+ session.flush()
+ _publish_task_update(task_id, _record_to_domain(record))
+
+
+def update_result(
+ session: Session,
+ task_id: int,
+ result_type: str,
+ result: dict,
+) -> None:
+ """写入任务结果。"""
+ record = session.get(GenerationTaskRecord, task_id)
+ if record is None:
+ return
+ record.result_type = result_type
+ record.result = result
+ record.status = TaskStatus.COMPLETED.value
+ record.update_at = datetime.now(timezone.utc)
+ session.flush()
+ _publish_task_update(task_id, _record_to_domain(record))
+
+
+# ── 读取 ─────────────────────────────────────────────────────────────────
+
+
+def get_task(session: Session, task_id: int) -> GenerationTask | None:
+ """按 task_id 查询任务。"""
+ record = session.get(GenerationTaskRecord, task_id)
+ if record is None:
+ return None
+ return _record_to_domain(record)
+
+
+def get_task_by_user(
+ session: Session,
+ user_id: int,
+ task_id: int,
+) -> GenerationTask | None:
+ """按 user_id + task_id 查询(校验归属)。"""
+ stmt = select(GenerationTaskRecord).where(
+ GenerationTaskRecord.id == task_id,
+ GenerationTaskRecord.user_id == user_id,
+ )
+ record = session.scalar(stmt)
+ if record is None:
+ return None
+ return _record_to_domain(record)
+
+
+def list_by_status(
+ session: Session, statuses: tuple[TaskStatus, ...],
+) -> list[GenerationTask]:
+ """按状态列出任务(启动对账用)。"""
+ values = [status.value for status in statuses]
+ rows = session.scalars(
+ select(GenerationTaskRecord).where(GenerationTaskRecord.status.in_(values))
+ ).all()
+ return [_record_to_domain(record) for record in rows]
+
+
+# ── 转换 ─────────────────────────────────────────────────────────────────
+
+
+def _record_to_domain(record: GenerationTaskRecord) -> GenerationTask:
+ """ORM 记录 → 领域 dataclass。"""
+ result = _deserialize_result(record.result_type, record.result)
+ return GenerationTask(
+ id=record.id,
+ user_id=record.user_id,
+ project_id=record.project_id,
+ task_type=GenerationType(record.task_type),
+ status=TaskStatus(record.status),
+ input_payload=record.input_payload,
+ result=result,
+ error_message=record.error_message,
+ create_at=record.create_at,
+ update_at=record.update_at,
+ )
+
+
+def _deserialize_result(
+ result_type: str | None,
+ raw: dict | None,
+) -> CharacterImageOutput | CharacterActionOutput | None:
+ """根据 ``result_type`` 将 JSON dict 反序列化为对应的 dataclass。"""
+ if raw is None or result_type is None:
+ return None
+ if result_type == "character_image":
+ return CharacterImageOutput(
+ type=raw.get("type", "character_image"),
+ image_urls=raw.get("image_urls", []),
+ )
+ if result_type == "character_action":
+ from windup_app.server.orchestrator.model import CharacterActionFrame
+
+ frames = [
+ CharacterActionFrame(
+ index=f["index"],
+ image_url=f["image_url"],
+ duration_ms=f.get("duration_ms"),
+ )
+ for f in raw.get("frames", [])
+ ]
+ return CharacterActionOutput(
+ type=raw.get("type", "character_action"),
+ action_type=raw.get("action_type", ""),
+ frames=frames,
+ )
+ return None
diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py
index 1bacf4fd..74618ece 100644
--- a/backend/packages/app/src/windup_app/server/project/interface.py
+++ b/backend/packages/app/src/windup_app/server/project/interface.py
@@ -31,8 +31,14 @@ def project_name_exists(self, session: Session, *, user_id: int, project_name: s
"""判断用户下的项目名称是否已存在。"""
@abstractmethod
- def get_project(self, session: Session, project_id: int) -> Project | None:
- """按 ID 查询项目。"""
+ def get_project(
+ self, session: Session, project_id: int, *, for_update: bool = False
+ ) -> Project | None:
+ """按 ID 查询项目。
+
+ ``for_update`` 为真时对项目行加 ``SELECT ... FOR UPDATE``,供角色创建与
+ 项目删除互斥,避免检查与写入之间插入角色。
+ """
@abstractmethod
def list_projects(
diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py
index 85cee08a..2845b348 100644
--- a/backend/packages/app/src/windup_app/server/project/service.py
+++ b/backend/packages/app/src/windup_app/server/project/service.py
@@ -32,8 +32,14 @@ def project_name_exists(self, session: Session, *, user_id: int, project_name: s
)
return session.scalar(stmt) is not None
- def get_project(self, session: Session, project_id: int) -> Project | None:
- return session.get(Project, project_id)
+ def get_project(
+ self, session: Session, project_id: int, *, for_update: bool = False
+ ) -> Project | None:
+ if not for_update:
+ return session.get(Project, project_id)
+ return session.scalar(
+ select(Project).where(Project.id == project_id).with_for_update()
+ )
def list_projects(
self, session: Session, *, page: int, page_size: int, user_id: int | None = None
diff --git a/backend/packages/app/src/windup_app/server/quota/.gitkeep b/backend/packages/app/src/windup_app/server/quota/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py
new file mode 100644
index 00000000..25b88e66
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/quota/interface.py
@@ -0,0 +1,111 @@
+"""积分领域服务抽象接口。
+
+API 层只依赖本模块定义的抽象,不感知具体实现(ORM / SQL)。
+"""
+
+from abc import ABC, abstractmethod
+
+from sqlalchemy.orm import Session
+
+from windup_app.server.quota.model import (
+ CreditAccountView,
+ CreditTransactionView,
+ InviteCode,
+ InviteCodeView,
+)
+
+
+class QuotaService(ABC):
+ """积分用例的稳定边界。"""
+
+ # -- 账户 ------------------------------------------------------------
+
+ @abstractmethod
+ def get_account(self, session: Session, user_id: int) -> CreditAccountView | None:
+ """查询用户积分账户。"""
+
+ # -- 预付费:冻结 / 扣减 / 解冻 ----------------------------------------
+
+ @abstractmethod
+ def reserve_credit(
+ self, session: Session, user_id: int, amount: int, ref_id: str
+ ) -> None:
+ """预付费冻结:从可用余额转移到冻结。
+
+ :raises BizException: 积分不足。
+ """
+
+ @abstractmethod
+ def capture_credit(
+ self,
+ session: Session,
+ user_id: int,
+ actual_amount: int,
+ ref_id: str,
+ frozen_amount: int,
+ ) -> None:
+ """预付费扣减:冻结转消耗。
+
+ 若 actual_amount < frozen_amount,差额自动退回可用余额。
+
+ :raises BizException: 冻结额度不足。
+ """
+
+ @abstractmethod
+ def release_credit(
+ self, session: Session, user_id: int, amount: int, ref_id: str
+ ) -> None:
+ """预付费解冻:冻结退回可用余额(任务失败时调用)。
+
+ :raises BizException: 冻结额度不足。
+ """
+
+ # -- 后付费:原子扣减(暂不实现,AGENT_TOKEN / POSTPAID 枚举已预留)------
+ #
+ # @abstractmethod
+ # def deduct_postpaid(
+ # self, session: Session, user_id: int, amount: int, ref_id: str
+ # ) -> None:
+ # """后付费原子扣减(Agent token 等)。"""
+
+ # -- 入账(赠送 / 奖励 / 管理员调整)----------------------------------
+
+ @abstractmethod
+ def credit(
+ self,
+ session: Session,
+ user_id: int,
+ amount: int,
+ reason: int,
+ ref_id: str | None = None,
+ ) -> None:
+ """入账:增加可用余额与累计获得。"""
+
+ # -- 流水查询 ---------------------------------------------------------
+
+ @abstractmethod
+ def list_transactions(
+ self, session: Session, user_id: int, page: int = 1, page_size: int = 20
+ ) -> tuple[list[CreditTransactionView], int]:
+ """分页查询积分流水,返回 (列表, 总数)。"""
+
+ # -- 邀请码 -----------------------------------------------------------
+
+ @abstractmethod
+ def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView:
+ """获取当前未过期邀请码;没有或已过期则签发新行。"""
+
+ @abstractmethod
+ def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView:
+ """签发新邀请码:插入新行,仍有效的旧码立即过期但保留。"""
+
+ @abstractmethod
+ def require_active_invite(self, session: Session, code: str) -> InviteCode:
+ """注册前校验邀请码存在且未过期。非法返回「邀请码无效」,过期返回「邀请码已过期」。"""
+
+ @abstractmethod
+ def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None:
+ """注册时兑换邀请码。被邀请人始终得邀请奖励;邀请人受每日人数上限。
+
+ :raises BizException: 邀请码无效 / 已过期 / 已填过码 / 不能填自己的码。
+ """
diff --git a/backend/packages/app/src/windup_app/server/quota/model.py b/backend/packages/app/src/windup_app/server/quota/model.py
new file mode 100644
index 00000000..e04a0ff3
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/quota/model.py
@@ -0,0 +1,227 @@
+"""积分模块领域模型。
+
+与数据库表一一对应,字段名与列名保持一致。
+
+ORM 模型
+--------
+
+::
+
+ windup_credit_account 积分账户(每用户一行)
+ windup_credit_transaction 积分流水(不可变账本)
+ windup_invite_code 邀请码
+ windup_invite_record 邀请记录
+ windup_token_usage Token 用量记录
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import (
+ BigInteger,
+ DateTime,
+ Integer,
+ SmallInteger,
+ String,
+ UniqueConstraint,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from windup_framework.config.quota import settings as quota_settings
+from windup_framework.db import Base
+
+
+# -- ORM ----------------------------------------------------------------
+
+
+class CreditAccount(Base):
+ """积分账户。"""
+
+ __tablename__ = "windup_credit_account"
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ user_id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ unique=True,
+ nullable=False,
+ )
+ balance: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ default=0,
+ )
+ frozen: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ default=0,
+ )
+ total_earned: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ default=0,
+ )
+ total_spent: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ default=0,
+ )
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+ update_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+
+class CreditTransaction(Base):
+ """积分流水(不可变账本)。"""
+
+ __tablename__ = "windup_credit_transaction"
+ __table_args__ = (
+ UniqueConstraint("ref_id", "reason", name="uq_credit_txn_ref_reason"),
+ )
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ user_id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ index=True,
+ )
+ delta: Mapped[int] = mapped_column(Integer, nullable=False)
+ reason: Mapped[int] = mapped_column(SmallInteger, nullable=False)
+ billing_mode: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
+ ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ balance_after: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ )
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+
+
+class InviteCode(Base):
+ """用户邀请码。只增不删;轮换插入新行,旧行保留。"""
+
+ __tablename__ = "windup_invite_code"
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ user_id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ index=True,
+ nullable=False,
+ )
+ code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
+ used_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ expires_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc)
+ + timedelta(days=quota_settings.invite_code_ttl_days),
+ )
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+ update_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+
+class InviteRecord(Base):
+ """一次成功的邀请关系。被邀请人只能出现一次。"""
+
+ __tablename__ = "windup_invite_record"
+
+ id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ )
+ inviter_id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ nullable=False,
+ index=True,
+ )
+ invitee_id: Mapped[int] = mapped_column(
+ BigInteger().with_variant(Integer, "sqlite"),
+ unique=True,
+ nullable=False,
+ )
+ code: Mapped[str] = mapped_column(String(16), nullable=False)
+ create_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+
+
+# class TokenUsage(Base):
+# """Token 用量记录。"""
+# __tablename__ = "windup_token_usage"
+# ...
+
+
+# -- 视图模型 ------------------------------------------------------------
+
+
+@dataclass
+class CreditAccountView:
+ """积分账户视图。"""
+
+ id: int | None = None
+ user_id: int = 0
+ balance: int = 0
+ frozen: int = 0
+ total_earned: int = 0
+ total_spent: int = 0
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+@dataclass
+class CreditTransactionView:
+ """积分流水视图。"""
+
+ id: int | None = None
+ user_id: int = 0
+ delta: int = 0
+ reason: int = 0
+ billing_mode: int = 0
+ ref_id: str | None = None
+ balance_after: int = 0
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+@dataclass
+class InviteCodeView:
+ """邀请码视图。"""
+
+ code: str
+ used_count: int = 0
+ expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py
new file mode 100644
index 00000000..e7a8d72f
--- /dev/null
+++ b/backend/packages/app/src/windup_app/server/quota/service.py
@@ -0,0 +1,491 @@
+"""积分领域服务的 SQLAlchemy 实现。
+
+:class:`SqlAlchemyQuotaService` 继承 :class:`QuotaService` 接口。
+
+事务边界由 ``windup_framework.db.get_session`` 依赖负责——成功 commit、异常
+rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。
+
+关键设计:
+- 预付费(生成任务):冻结 → 扣减/解冻,行级锁 + 幂等 ref_id
+- 后付费(Agent token):原子 UPDATE WHERE balance >= amount,无需行锁
+- 入账(赠送/奖励):余额 + 累计获得同步递增
+"""
+
+import logging
+import re
+import secrets
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from windup_common.enums.biz_code import BizCode
+from windup_common.enums.quota import BillingMode, CreditReason
+from windup_common.exceptions import BizException
+
+from windup_app.server.quota.interface import QuotaService
+from windup_app.server.quota.model import (
+ CreditAccount,
+ CreditAccountView,
+ CreditTransaction,
+ CreditTransactionView,
+ InviteCode,
+ InviteCodeView,
+ InviteRecord,
+)
+from windup_app.server.user.model import User
+from windup_framework.config.quota import settings as quota_settings
+
+logger = logging.getLogger("windup.quota.service")
+
+_INVITE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+_INVITE_CODE_LENGTH = 8
+_INVITE_CODE_RE = re.compile(rf"^[{re.escape(_INVITE_ALPHABET)}]{{4,16}}$")
+
+
+def normalize_invite_code(code: str) -> str:
+ return code.strip().upper()
+
+
+def parse_invite_code(code: str) -> str:
+ """解析邀请链接传入的邀请码,字符集与前端 INVITE_CODE_PATTERN 一致。"""
+ normalized = normalize_invite_code(code)
+ if _INVITE_CODE_RE.fullmatch(normalized) is None:
+ raise BizException("邀请码无效", code=BizCode.BAD_REQUEST)
+ return normalized
+
+
+def _new_invite_code() -> str:
+ return "".join(secrets.choice(_INVITE_ALPHABET) for _ in range(_INVITE_CODE_LENGTH))
+
+
+def _is_invitee_unique_violation(exc: IntegrityError) -> bool:
+ text = f"{getattr(exc, 'orig', '')} {exc}".lower()
+ return "invitee" in text or "windup_invite_record" in text
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _utc_day_start(now: datetime | None = None) -> datetime:
+ current = now or _now()
+ if current.tzinfo is None:
+ current = current.replace(tzinfo=timezone.utc)
+ return current.astimezone(timezone.utc).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+
+
+def _is_expired(expires_at: datetime) -> bool:
+ exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc)
+ return exp <= _now()
+
+
+def _to_invite_view(row: InviteCode) -> InviteCodeView:
+ return InviteCodeView(
+ code=row.code,
+ used_count=row.used_count,
+ expires_at=row.expires_at,
+ create_at=row.create_at,
+ update_at=row.update_at,
+ )
+
+
+def _to_account_view(account: CreditAccount) -> CreditAccountView:
+ return CreditAccountView(
+ id=account.id,
+ user_id=account.user_id,
+ balance=account.balance,
+ frozen=account.frozen,
+ total_earned=account.total_earned,
+ total_spent=account.total_spent,
+ create_at=account.create_at,
+ update_at=account.update_at,
+ )
+
+
+def _to_txn_view(txn: CreditTransaction) -> CreditTransactionView:
+ return CreditTransactionView(
+ id=txn.id,
+ user_id=txn.user_id,
+ delta=txn.delta,
+ reason=txn.reason,
+ billing_mode=txn.billing_mode,
+ ref_id=txn.ref_id,
+ balance_after=txn.balance_after,
+ create_at=txn.create_at,
+ )
+
+
+class SqlAlchemyQuotaService(QuotaService):
+ """基于 SQLAlchemy session 的积分服务实现。"""
+
+ # -- 账户 ------------------------------------------------------------
+
+ def get_account(self, session: Session, user_id: int) -> CreditAccountView | None:
+ account = session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == user_id)
+ )
+ return _to_account_view(account) if account else None
+
+ def _get_account_for_update(self, session: Session, user_id: int) -> CreditAccount:
+ """SELECT ... FOR UPDATE 锁定账户行。"""
+ account = session.scalar(
+ select(CreditAccount)
+ .where(CreditAccount.user_id == user_id)
+ .with_for_update()
+ )
+ if account is None:
+ raise BizException("积分账户不存在", code=BizCode.NOT_FOUND)
+ return account
+
+ def _write_txn(
+ self,
+ session: Session,
+ user_id: int,
+ delta: int,
+ reason: int,
+ billing_mode: int,
+ balance_after: int,
+ ref_id: str | None = None,
+ ) -> CreditTransaction:
+ """写入一条流水记录。"""
+ txn = CreditTransaction(
+ user_id=user_id,
+ delta=delta,
+ reason=reason,
+ billing_mode=billing_mode,
+ ref_id=ref_id,
+ balance_after=balance_after,
+ )
+ session.add(txn)
+ return txn
+
+ # -- 预付费:冻结 / 扣减 / 解冻 ----------------------------------------
+
+ def reserve_credit(
+ self, session: Session, user_id: int, amount: int, ref_id: str
+ ) -> None:
+ """预付费冻结:balance -= amount, frozen += amount。"""
+ account = self._get_account_for_update(session, user_id)
+
+ if account.balance < amount:
+ raise BizException(
+ f"积分不足(需要 {amount},当前 {account.balance})",
+ code=BizCode.BAD_REQUEST,
+ )
+
+ account.balance -= amount
+ account.frozen += amount
+ session.flush()
+
+ self._write_txn(
+ session,
+ user_id,
+ -amount,
+ CreditReason.FROZEN,
+ BillingMode.PREPAID,
+ account.balance,
+ ref_id,
+ )
+
+ logger.info(
+ "[WINDUP] 积分冻结 | user_id=%s amount=%s ref_id=%s balance=%s",
+ user_id,
+ amount,
+ ref_id,
+ account.balance,
+ )
+
+ def capture_credit(
+ self,
+ session: Session,
+ user_id: int,
+ actual_amount: int,
+ ref_id: str,
+ frozen_amount: int,
+ ) -> None:
+ """预付费扣减:frozen -= frozen_amount, total_spent += actual_amount。
+
+ 若 actual_amount < frozen_amount,差额退回 balance。
+ """
+ account = self._get_account_for_update(session, user_id)
+
+ if account.frozen < frozen_amount:
+ raise BizException(
+ f"冻结额度不足(需要 {frozen_amount},当前冻结 {account.frozen})",
+ code=BizCode.BAD_REQUEST,
+ )
+
+ # 冻结释放
+ account.frozen -= frozen_amount
+ # 实际消耗
+ account.total_spent += actual_amount
+
+ # 差额退回
+ refund = frozen_amount - actual_amount
+ if refund > 0:
+ account.balance += refund
+
+ session.flush()
+
+ # 写扣减流水
+ self._write_txn(
+ session,
+ user_id,
+ -actual_amount,
+ CreditReason.CAPTURED,
+ BillingMode.PREPAID,
+ account.balance,
+ ref_id,
+ )
+
+ # 有差额退回时写退款流水(用不同 reason 区分,ref_id 加后缀去重)
+ if refund > 0:
+ self._write_txn(
+ session,
+ user_id,
+ refund,
+ CreditReason.REFUND,
+ BillingMode.PREPAID,
+ account.balance,
+ f"{ref_id}:refund",
+ )
+
+ logger.info(
+ "[WINDUP] 积分扣减 | user_id=%s actual=%s frozen=%s refund=%s balance=%s",
+ user_id,
+ actual_amount,
+ frozen_amount,
+ refund,
+ account.balance,
+ )
+
+ def release_credit(
+ self, session: Session, user_id: int, amount: int, ref_id: str
+ ) -> None:
+ """预付费解冻:frozen -= amount, balance += amount。"""
+ account = self._get_account_for_update(session, user_id)
+
+ if account.frozen < amount:
+ raise BizException(
+ f"冻结额度不足(需要 {amount},当前冻结 {account.frozen})",
+ code=BizCode.BAD_REQUEST,
+ )
+
+ account.frozen -= amount
+ account.balance += amount
+ session.flush()
+
+ self._write_txn(
+ session,
+ user_id,
+ amount,
+ CreditReason.REFUND,
+ BillingMode.PREPAID,
+ account.balance,
+ f"{ref_id}:release",
+ )
+
+ logger.info(
+ "[WINDUP] 积分解冻 | user_id=%s amount=%s ref_id=%s balance=%s",
+ user_id,
+ amount,
+ ref_id,
+ account.balance,
+ )
+
+ # -- 后付费:原子扣减(暂不实现,AGENT_TOKEN / POSTPAID 枚举已预留)------
+ #
+ # def deduct_postpaid(
+ # self, session: Session, user_id: int, amount: int, ref_id: str
+ # ) -> None:
+ # """后付费原子扣减:UPDATE ... WHERE balance >= amount。"""
+ # ...
+
+ # -- 入账(赠送 / 奖励 / 管理员调整)----------------------------------
+
+ def credit(
+ self,
+ session: Session,
+ user_id: int,
+ amount: int,
+ reason: int,
+ ref_id: str | None = None,
+ ) -> None:
+ """入账:balance += amount, total_earned += amount。"""
+ if amount <= 0:
+ return
+
+ account = self._get_account_for_update(session, user_id)
+ account.balance += amount
+ account.total_earned += amount
+ session.flush()
+
+ self._write_txn(
+ session,
+ user_id,
+ amount,
+ reason,
+ BillingMode.PREPAID,
+ account.balance,
+ ref_id,
+ )
+
+ logger.info(
+ "[WINDUP] 积分入账 | user_id=%s amount=%s reason=%s balance=%s",
+ user_id,
+ amount,
+ reason,
+ account.balance,
+ )
+
+ # -- 流水查询 ---------------------------------------------------------
+
+ def list_transactions(
+ self, session: Session, user_id: int, page: int = 1, page_size: int = 20
+ ) -> tuple[list[CreditTransactionView], int]:
+ """分页查询积分流水。"""
+ total = session.scalar(
+ select(func.count())
+ .select_from(CreditTransaction)
+ .where(CreditTransaction.user_id == user_id)
+ )
+
+ rows = session.scalars(
+ select(CreditTransaction)
+ .where(CreditTransaction.user_id == user_id)
+ .order_by(CreditTransaction.id.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ ).all()
+
+ return [_to_txn_view(r) for r in rows], total or 0
+
+ # -- 邀请码 -----------------------------------------------------------
+
+ def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView:
+ row = session.scalar(
+ select(InviteCode)
+ .where(InviteCode.user_id == user_id, InviteCode.expires_at > _now())
+ .order_by(InviteCode.id.desc())
+ )
+ if row is not None:
+ return _to_invite_view(row)
+ return self.generate_invite_code(session, user_id)
+
+ def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView:
+ if session.get(User, user_id) is None:
+ raise BizException("用户不存在", code=BizCode.NOT_FOUND)
+
+ now = _now()
+ existing = session.scalars(
+ select(InviteCode)
+ .where(InviteCode.user_id == user_id)
+ .with_for_update()
+ ).all()
+ for row in existing:
+ if not _is_expired(row.expires_at):
+ row.expires_at = now
+
+ row = InviteCode(
+ user_id=user_id,
+ code=self._allocate_invite_code(session),
+ used_count=0,
+ expires_at=now
+ + timedelta(days=quota_settings.invite_code_ttl_days),
+ )
+ session.add(row)
+ session.flush()
+ logger.info("[WINDUP] 生成邀请码 | user_id=%s code=%s", user_id, row.code)
+ return _to_invite_view(row)
+
+ def _allocate_invite_code(self, session: Session) -> str:
+ for _ in range(16):
+ code = _new_invite_code()
+ if session.scalar(select(InviteCode.id).where(InviteCode.code == code)) is None:
+ return code
+ raise BizException("邀请码生成失败,请稍后重试", code=BizCode.BAD_REQUEST)
+
+ def require_active_invite(self, session: Session, code: str) -> InviteCode:
+ normalized = parse_invite_code(code)
+ invite = session.scalar(
+ select(InviteCode).where(InviteCode.code == normalized)
+ )
+ if invite is None:
+ raise BizException("邀请码无效", code=BizCode.BAD_REQUEST)
+ if _is_expired(invite.expires_at):
+ raise BizException("邀请码已过期", code=BizCode.NOT_FOUND)
+ return invite
+
+ def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None:
+ invite = self.require_active_invite(session, code)
+ if invite.user_id == user_id:
+ raise BizException("不能填写自己的邀请码", code=BizCode.BAD_REQUEST)
+ if session.get(User, user_id) is None:
+ raise BizException("用户不存在", code=BizCode.NOT_FOUND)
+
+ existing = session.scalar(
+ select(InviteRecord.id).where(InviteRecord.invitee_id == user_id)
+ )
+ if existing is not None:
+ raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST)
+
+ self._get_account_for_update(session, invite.user_id)
+
+ record = InviteRecord(
+ inviter_id=invite.user_id,
+ invitee_id=user_id,
+ code=invite.code,
+ )
+ session.add(record)
+ invite.used_count += 1
+ try:
+ session.flush()
+ except IntegrityError as exc:
+ if _is_invitee_unique_violation(exc):
+ raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) from exc
+ raise
+
+ reward = quota_settings.invite_reward_amount
+ today_count = session.scalar(
+ select(func.count())
+ .select_from(InviteRecord)
+ .where(
+ InviteRecord.inviter_id == invite.user_id,
+ InviteRecord.create_at >= _utc_day_start(),
+ )
+ ) or 0
+ if today_count <= quota_settings.invite_reward_daily_limit:
+ self.credit(
+ session,
+ invite.user_id,
+ reward,
+ int(CreditReason.INVITE_REWARD),
+ f"invite:{user_id}:inviter",
+ )
+ else:
+ logger.info(
+ "[WINDUP] 邀请人日限额已满,跳过邀请人奖励 | inviter=%s invitee=%s count=%s",
+ invite.user_id,
+ user_id,
+ today_count,
+ )
+ self.credit(
+ session,
+ user_id,
+ reward,
+ int(CreditReason.INVITE_REWARD),
+ f"invite:{user_id}:invitee",
+ )
+ logger.info(
+ "[WINDUP] 兑换邀请码 | invitee=%s inviter=%s code=%s",
+ user_id,
+ invite.user_id,
+ invite.code,
+ )
+
+
+service = SqlAlchemyQuotaService()
diff --git a/backend/packages/app/src/windup_app/server/user/interface.py b/backend/packages/app/src/windup_app/server/user/interface.py
index 84c22755..6ea3d826 100644
--- a/backend/packages/app/src/windup_app/server/user/interface.py
+++ b/backend/packages/app/src/windup_app/server/user/interface.py
@@ -1,10 +1,15 @@
"""用户领域服务抽象接口。
API 层只依赖本模块定义的抽象,不感知具体实现(ORM / Redis / Resend)。
+
+约定为 session-per-call:需要落库的方法由调用方传入 ``session``(FastAPI 的
+``get_session`` 依赖),实现保持无状态,可作为模块级单例。
"""
from abc import ABC, abstractmethod
+from sqlalchemy.orm import Session
+
from windup_app.server.user.model import (
ChangePasswordInput,
LoginByCodeInput,
@@ -21,16 +26,18 @@ class UserService(ABC):
# -- 注册 ------------------------------------------------------------
@abstractmethod
- def register_by_email(self, input: RegisterInput) -> LoginResult:
- """邮箱+密码注册,注册成功即登录。
+ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult:
+ """邮箱+验证码+密码注册。邀请码选填。
- :raises windup_common.exceptions.BizException: 邮箱已注册。
+ :raises windup_common.exceptions.BizException: 邮箱已注册 / 邀请码无效。
"""
# -- 登录 ------------------------------------------------------------
@abstractmethod
- def login_by_password(self, input: LoginByPasswordInput) -> LoginResult:
+ def login_by_password(
+ self, session: Session, input: LoginByPasswordInput
+ ) -> LoginResult:
"""邮箱+密码登录。
:raises windup_common.exceptions.BizException: 邮箱不存在 / 密码错误 / 账号已封禁。
@@ -45,8 +52,8 @@ def send_verification_code(self, email: str, purpose: str) -> None:
"""
@abstractmethod
- def login_by_code(self, input: LoginByCodeInput) -> LoginResult:
- """邮箱+验证码登录,无账号时自动注册。
+ def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult:
+ """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。
:raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号已封禁。
"""
@@ -73,7 +80,9 @@ def refresh_tokens(self, refresh_token: str) -> LoginResult:
# -- 密码 ------------------------------------------------------------
@abstractmethod
- def change_password(self, user_id: int, input: ChangePasswordInput) -> None:
+ def change_password(
+ self, session: Session, user_id: int, input: ChangePasswordInput
+ ) -> None:
"""修改密码(需验证旧密码)。
:raises windup_common.exceptions.BizException: 旧密码错误。
@@ -82,9 +91,9 @@ def change_password(self, user_id: int, input: ChangePasswordInput) -> None:
# -- 查询 ------------------------------------------------------------
@abstractmethod
- def get_by_id(self, user_id: int) -> UserView | None:
+ def get_by_id(self, session: Session, user_id: int) -> UserView | None:
"""按 ID 查询用户。"""
@abstractmethod
- def get_by_email(self, email: str) -> UserView | None:
+ def get_by_email(self, session: Session, email: str) -> UserView | None:
"""按邮箱查询用户。"""
diff --git a/backend/packages/app/src/windup_app/server/user/model.py b/backend/packages/app/src/windup_app/server/user/model.py
index 31b51464..c7460228 100644
--- a/backend/packages/app/src/windup_app/server/user/model.py
+++ b/backend/packages/app/src/windup_app/server/user/model.py
@@ -115,6 +115,7 @@ class RegisterInput:
password: str
code: str
nickname: str | None = None
+ invite_code: str | None = None
@dataclass
diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py
index aea85033..572cf099 100644
--- a/backend/packages/app/src/windup_app/server/user/service.py
+++ b/backend/packages/app/src/windup_app/server/user/service.py
@@ -9,7 +9,7 @@
import hashlib
import logging
-import random
+import secrets
import string
import uuid
from datetime import datetime, timezone
@@ -21,8 +21,11 @@
from sqlalchemy.orm import Session
from windup_common.enums.biz_code import BizCode
+from windup_common.enums.quota import CreditReason
from windup_common.exceptions import BizException
+from windup_framework.config.quota import settings as quota_settings
+from windup_app.server.quota.model import CreditAccount, CreditTransaction
from windup_app.server.user.interface import UserService
from windup_app.server.user.model import (
ChangePasswordInput,
@@ -44,9 +47,9 @@
# -- JWT 配置 -------------------------------------------------------------
-JWT_SECRET = jwt_settings.secret
+JWT_SECRET = jwt_settings.secret.get_secret_value()
JWT_ALGORITHM = "HS256"
-ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟
+ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟
REFRESH_TOKEN_EXPIRE_SECONDS = 7 * 24 * 3600 # 7 天
# -- 密码哈希 -------------------------------------------------------------
@@ -61,6 +64,7 @@ def _verify_password(password: str, hashed: str) -> bool:
"""验证密码。"""
return bcrypt.checkpw(password.encode(), hashed.encode())
+
# -- Redis key 前缀 -------------------------------------------------------
VERIFY_COOLDOWN_KEY = "verify:cooldown:{email}"
@@ -69,12 +73,12 @@ def _verify_password(password: str, hashed: str) -> bool:
LOGIN_FAIL_KEY = "login:fail:{email}"
LOGIN_LOCK_KEY = "login:lock:{email}"
-VERIFY_CODE_TTL = 300 # 5 分钟
-COOLDOWN_TTL = 60 # 60 秒
+VERIFY_CODE_TTL = 300 # 5 分钟
+COOLDOWN_TTL = 60 # 60 秒
-LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限
-LOGIN_FAIL_WINDOW = 15 * 60 # 失败计数窗口 15 分钟
-LOGIN_LOCK_DURATION = 15 * 60 # 锁定时长 15 分钟
+LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限
+LOGIN_FAIL_WINDOW = 15 * 60 # 失败计数窗口 15 分钟
+LOGIN_LOCK_DURATION = 15 * 60 # 锁定时长 15 分钟
def _hash_token(token: str) -> str:
@@ -83,8 +87,8 @@ def _hash_token(token: str) -> str:
def _generate_code() -> str:
- """生成 6 位数字验证码。"""
- return "".join(random.choices(string.digits, k=6))
+ """生成 6 位数字验证码(密码学安全)。"""
+ return "".join(secrets.choice(string.digits) for _ in range(6))
# -- User → UserView 转换 ------------------------------------------------
@@ -163,18 +167,18 @@ def redis(self) -> redis_lib.Redis:
# -- 注册 ------------------------------------------------------------
- def register_by_email(self, input: RegisterInput) -> LoginResult:
- # 检查邮箱是否已注册(通过全局 session,这里需要外部传入)
- # 由于接口签名不含 session,改为类级持有或工厂注入
- # 但当前项目模式是 service 单例 + session 由调用方传入
- # 此处需要重构:register 不走 session 查询,直接用内部方法
- raise NotImplementedError("请通过 API 层调用带 session 的版本")
+ def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult:
+ """邮箱+验证码+密码注册。邀请码选填。"""
+ from windup_app.server.quota.service import (
+ parse_invite_code,
+ service as quota_service,
+ )
+
+ raw_invite = (input.invite_code or "").strip()
+ invite_code = parse_invite_code(raw_invite) if raw_invite else None
+ if invite_code is not None:
+ quota_service.require_active_invite(session, invite_code)
- def register_by_email_with_session(
- self, session: Session, input: RegisterInput
- ) -> LoginResult:
- """邮箱+验证码+密码注册(带 session)。"""
- # 校验验证码
self._verify_code(input.email, input.code, "register")
# 检查邮箱唯一
@@ -188,11 +192,18 @@ def register_by_email_with_session(
email=input.email,
password_hash=_hash_password(input.password),
nickname=input.nickname,
- email_verified_at=datetime.now(timezone.utc), # 注册即验证(已通过验证码校验)
+ email_verified_at=datetime.now(
+ timezone.utc
+ ), # 注册即验证(已通过验证码校验)
)
session.add(user)
session.flush()
+ # 注册送积分;有邀请码再发双方邀请奖励
+ self._create_credit_account(session, user.id)
+ if invite_code is not None:
+ quota_service.redeem_invite_code(session, user.id, invite_code)
+
# 注册即登录,签发 token
access_token = create_access_token(user.id, user.email)
refresh_token, jti = create_refresh_token(user.id, user.email)
@@ -230,13 +241,10 @@ def _clear_login_failures(self, email: str) -> None:
# -- 登录 ------------------------------------------------------------
- def login_by_password(self, input: LoginByPasswordInput) -> LoginResult:
- raise NotImplementedError("请通过 API 层调用带 session 的版本")
-
- def login_by_password_with_session(
+ def login_by_password(
self, session: Session, input: LoginByPasswordInput
) -> LoginResult:
- """邮箱+密码登录(带 session)。"""
+ """邮箱+密码登录。"""
# 检查账号锁定
self._check_login_lock(input.email)
@@ -277,7 +285,9 @@ def send_verification_code(self, email: str, purpose: str) -> None:
# 频率限制
cooldown_key = VERIFY_COOLDOWN_KEY.format(email=email)
if self.redis.get(cooldown_key):
- raise BizException("发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS)
+ raise BizException(
+ "发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS
+ )
code = _generate_code()
code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email)
@@ -303,27 +313,24 @@ def _verify_code(self, email: str, code: str, purpose: str) -> None:
# 验证通过,删除验证码
self.redis.delete(code_key)
- def login_by_code(self, input: LoginByCodeInput) -> LoginResult:
- raise NotImplementedError("请通过 API 层调用带 session 的版本")
-
- def login_by_code_with_session(
- self, session: Session, input: LoginByCodeInput
- ) -> LoginResult:
- """邮箱+验证码登录,无账号自动注册(带 session)。"""
+ def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult:
+ """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。"""
# 校验验证码
self._verify_code(input.email, input.code, "login")
- # 查找或创建用户
user = session.scalar(select(User).where(User.email == input.email))
if user is None:
- user = User(email=input.email, email_verified_at=datetime.now(timezone.utc))
+ user = User(
+ email=input.email,
+ password_hash="",
+ email_verified_at=datetime.now(timezone.utc),
+ )
session.add(user)
session.flush()
- logger.info("[WINDUP] 验证码自动注册 | user_id=%s email=%s", user.id, user.email)
+ self._create_credit_account(session, user.id)
else:
if user.status == UserStatus.BANNED:
raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST)
- # 标记邮箱已验证
if user.email_verified_at is None:
user.email_verified_at = datetime.now(timezone.utc)
@@ -372,6 +379,24 @@ def validate_access_token(self, token: str) -> UserView | None:
email=payload.get("email", ""),
)
+ # -- Lua: 原子 检查-删除-存储 refresh token --------------------------------
+ # KEYS[1] = old_token_key, KEYS[2] = new_token_key
+ # ARGV[1] = ttl, ARGV[2] = user_id
+ # 返回: user_id (成功) 或 nil (旧 token 不存在/已被消费)
+ _ROTATE_TOKEN_SCRIPT = """
+ local old_key = KEYS[1]
+ local new_key = KEYS[2]
+ local ttl = tonumber(ARGV[1])
+ local user_id = ARGV[2]
+ local cur = redis.call('GET', old_key)
+ if cur == false then
+ return nil
+ end
+ redis.call('DEL', old_key)
+ redis.call('SETEX', new_key, ttl, user_id)
+ return cur
+ """
+
def refresh_tokens(self, refresh_token: str) -> LoginResult:
"""刷新 token。"""
payload = decode_token(refresh_token)
@@ -382,24 +407,32 @@ def refresh_tokens(self, refresh_token: str) -> LoginResult:
if not jti:
raise BizException("token 无效", code=BizCode.UNAUTHORIZED)
+ # user_id 来自已验签的 JWT,可信
+ user_id = int(payload["sub"])
+ email = payload.get("email", "")
+
+ # 签发新 token
+ new_access = create_access_token(user_id, email)
+ new_refresh, new_jti = create_refresh_token(user_id, email)
+
+ # Lua 原子操作:GET old → 存在则 DEL old + SETEX new → 返回 user_id
token_hash = _hash_token(jti)
- redis_key = REFRESH_TOKEN_KEY.format(token_hash=token_hash)
- user_id_str = self.redis.get(redis_key)
+ old_redis_key = REFRESH_TOKEN_KEY.format(token_hash=token_hash)
+ new_token_hash = _hash_token(new_jti)
+ new_redis_key = REFRESH_TOKEN_KEY.format(token_hash=new_token_hash)
+
+ user_id_str = self.redis.eval(
+ self._ROTATE_TOKEN_SCRIPT,
+ 2,
+ old_redis_key,
+ new_redis_key,
+ REFRESH_TOKEN_EXPIRE_SECONDS,
+ str(user_id),
+ )
if user_id_str is None:
raise BizException("refresh token 已失效", code=BizCode.UNAUTHORIZED)
- user_id = int(user_id_str)
-
- # 撤销旧 token
- self.redis.delete(redis_key)
-
- # 签发新 token(需要 email,从旧 token payload 取)
- email = payload.get("email", "")
- new_access = create_access_token(user_id, email)
- new_refresh, new_jti = create_refresh_token(user_id, email)
- self._store_refresh_token(new_jti, user_id)
-
logger.info("[WINDUP] token 已刷新 | user_id=%s", user_id)
return LoginResult(
user=UserView(id=user_id, email=email),
@@ -409,13 +442,10 @@ def refresh_tokens(self, refresh_token: str) -> LoginResult:
# -- 密码 ------------------------------------------------------------
- def change_password(self, user_id: int, input: ChangePasswordInput) -> None:
- raise NotImplementedError("请通过 API 层调用带 session 的版本")
-
- def change_password_with_session(
+ def change_password(
self, session: Session, user_id: int, input: ChangePasswordInput
) -> None:
- """修改密码(带 session)。"""
+ """修改密码。"""
user = session.get(User, user_id)
if user is None:
raise BizException("用户不存在", code=BizCode.NOT_FOUND)
@@ -430,9 +460,7 @@ def change_password_with_session(
self._revoke_all_user_tokens(user_id)
logger.info("[WINDUP] 密码已修改 | user_id=%s", user_id)
- def reset_password_with_session(
- self, session: Session, input: ResetPasswordInput
- ) -> None:
+ def reset_password(self, session: Session, input: ResetPasswordInput) -> None:
"""邮箱+验证码重置密码(忘记密码场景)。"""
# 校验验证码(purpose 必须为 reset_password)
self._verify_code(input.email, input.code, "reset_password")
@@ -453,10 +481,10 @@ def reset_password_with_session(
# -- 昵称 ------------------------------------------------------------
- def update_nickname_with_session(
+ def update_nickname(
self, session: Session, user_id: int, input: UpdateNicknameInput
) -> UserView:
- """修改昵称(带 session)。"""
+ """修改昵称。"""
user = session.get(User, user_id)
if user is None:
raise BizException("用户不存在", code=BizCode.NOT_FOUND)
@@ -469,23 +497,45 @@ def update_nickname_with_session(
# -- 查询 ------------------------------------------------------------
- def get_by_id(self, user_id: int) -> UserView | None:
- # 需要 session,由 API 层直接查 ORM
- raise NotImplementedError("请通过 API 层直接查询 ORM")
-
- def get_by_email(self, email: str) -> UserView | None:
- raise NotImplementedError("请通过 API 层直接查询 ORM")
-
- def get_by_id_with_session(self, session: Session, user_id: int) -> UserView | None:
+ def get_by_id(self, session: Session, user_id: int) -> UserView | None:
user = session.get(User, user_id)
return _to_view(user) if user else None
- def get_by_email_with_session(self, session: Session, email: str) -> UserView | None:
+ def get_by_email(self, session: Session, email: str) -> UserView | None:
user = session.scalar(select(User).where(User.email == email))
return _to_view(user) if user else None
# -- 内部方法 --------------------------------------------------------
+ def _create_credit_account(self, session: Session, user_id: int) -> None:
+ """注册时创建积分账户并赠送初始积分。"""
+ account = CreditAccount(
+ user_id=user_id,
+ balance=quota_settings.register_gift_amount,
+ frozen=0,
+ total_earned=quota_settings.register_gift_amount,
+ total_spent=0,
+ )
+ session.add(account)
+ session.flush()
+
+ txn = CreditTransaction(
+ user_id=user_id,
+ delta=quota_settings.register_gift_amount,
+ reason=CreditReason.REGISTER_GIFT,
+ billing_mode=0, # PREPAID
+ ref_id=f"register:{user_id}",
+ balance_after=quota_settings.register_gift_amount,
+ )
+ session.add(txn)
+ session.flush()
+
+ logger.info(
+ "[WINDUP] 注册送积分 | user_id=%s amount=%s",
+ user_id,
+ quota_settings.register_gift_amount,
+ )
+
def _store_refresh_token(self, jti: str, user_id: int) -> None:
"""将 refresh_token 存入 Redis。"""
token_hash = _hash_token(jti)
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/interface.py b/backend/packages/app/src/windup_app/server/workflow_run/interface.py
index 3fe597a1..bfd0a319 100644
--- a/backend/packages/app/src/windup_app/server/workflow_run/interface.py
+++ b/backend/packages/app/src/windup_app/server/workflow_run/interface.py
@@ -60,15 +60,19 @@ def update_run(
session: Session,
run_id: int,
*,
+ expected_version: int,
nodes: list | None = None,
status: RunStatus | None = None,
) -> WorkflowRun | None:
"""更新执行记录。
前端维护节点树后,通过此接口全量写回。
+ ``expected_version`` 必须等于库中当前版本,否则乐观锁冲突。
+ 无字段变更时不递增 version,但仍校验版本。
+ 只写入请求明确提供且确有变化的列。
返回更新后的记录;不存在时返回 None。
"""
@abstractmethod
def delete_run(self, session: Session, run_id: int) -> bool:
- """软删除执行记录。返回是否找到。"""
+ """软删除执行记录。命中时递增 version,与 PATCH 共用乐观锁。返回是否找到。"""
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/schema.py b/backend/packages/app/src/windup_app/server/workflow_run/schema.py
index 9c571bb4..84a60a8c 100644
--- a/backend/packages/app/src/windup_app/server/workflow_run/schema.py
+++ b/backend/packages/app/src/windup_app/server/workflow_run/schema.py
@@ -30,10 +30,12 @@ class WorkflowRunUpdateRequest(BaseModel):
"""全量更新执行记录。
前端维护节点树后,通过此接口全量写回。
+ ``version`` 必须等于当前记录版本,否则乐观锁冲突。
"""
- nodes: list = Field(
- default_factory=list,
+ version: int = Field(ge=1, description="客户端读到的当前版本号")
+ nodes: list | None = Field(
+ default=None,
description="节点树(前端自定义结构,后端不校验)",
)
status: str | None = Field(
@@ -51,4 +53,4 @@ class WorkflowRunOut(BaseModel):
project_id: int
nodes: list = Field(default_factory=list, description="节点树(前端自定义结构)")
status: str = Field(description="active / soft_deleted")
- version: int = Field(description="版本号,从 1 递增")
+ version: int = Field(description="乐观锁版本号,从 1 递增")
diff --git a/backend/packages/app/src/windup_app/server/workflow_run/service.py b/backend/packages/app/src/windup_app/server/workflow_run/service.py
index 916e8b64..cf6e723e 100644
--- a/backend/packages/app/src/windup_app/server/workflow_run/service.py
+++ b/backend/packages/app/src/windup_app/server/workflow_run/service.py
@@ -8,13 +8,23 @@
rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。
"""
-from sqlalchemy import func, select
+from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
+from windup_common.enums.biz_code import BizCode
+from windup_common.exceptions import BizException
+
from windup_app.server.workflow_run.interface import WorkflowRunService
from windup_app.server.workflow_run.model import RunStatus, WorkflowRun
+def _version_conflict() -> None:
+ raise BizException(
+ "执行记录版本冲突,请刷新后重试",
+ code=BizCode.CONFLICT,
+ )
+
+
class SqlAlchemyWorkflowRunService(WorkflowRunService):
"""基于 SQLAlchemy session 的执行记录 CRUD 实现。"""
@@ -72,27 +82,56 @@ def update_run(
session: Session,
run_id: int,
*,
+ expected_version: int,
nodes: list | None = None,
status: RunStatus | None = None,
) -> WorkflowRun | None:
run = session.get(WorkflowRun, run_id)
if run is None:
return None
- if nodes is not None:
- run.nodes = nodes
- if status is not None:
- run.status = status.value
- run.version += 1
- session.flush()
+
+ values: dict[str, object] = {}
+ if nodes is not None and nodes != run.nodes:
+ values["nodes"] = nodes
+ if status is not None and status.value != run.status:
+ values["status"] = status.value
+
+ if not values:
+ if run.version != expected_version:
+ _version_conflict()
+ return run
+
+ values["version"] = expected_version + 1
+ result = session.execute(
+ update(WorkflowRun)
+ .where(
+ WorkflowRun.id == run_id,
+ WorkflowRun.version == expected_version,
+ )
+ .values(**values)
+ .execution_options(synchronize_session="fetch")
+ )
+ if result.rowcount == 0:
+ _version_conflict()
+ session.refresh(run)
return run
def delete_run(self, session: Session, run_id: int) -> bool:
run = session.get(WorkflowRun, run_id)
if run is None:
return False
- run.status = RunStatus.SOFT_DELETED.value
- session.flush()
- return True
+ if run.status == RunStatus.SOFT_DELETED.value:
+ return True
+ result = session.execute(
+ update(WorkflowRun)
+ .where(WorkflowRun.id == run_id)
+ .values(
+ status=RunStatus.SOFT_DELETED.value,
+ version=WorkflowRun.version + 1,
+ )
+ .execution_options(synchronize_session="fetch")
+ )
+ return result.rowcount > 0
service = SqlAlchemyWorkflowRunService()
diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py
index b196a024..eb76d7db 100644
--- a/backend/packages/app/src/windup_app/web/api/auth.py
+++ b/backend/packages/app/src/windup_app/web/api/auth.py
@@ -6,14 +6,20 @@
import logging
from fastapi import APIRouter, Depends, Request
-from pydantic import BaseModel, ConfigDict, Field, EmailStr
+from pydantic import BaseModel, ConfigDict, Field, EmailStr, field_validator
from sqlalchemy.orm import Session
from windup_common.result import Response
from windup_framework.db import get_session
-from windup_app.server.user.model import ResetPasswordInput, UpdateNicknameInput, User, UserView
+from windup_app.server.user.model import (
+ RegisterInput,
+ ResetPasswordInput,
+ UpdateNicknameInput,
+ User,
+ UserView,
+)
from windup_app.server.user.service import service
logger = logging.getLogger("windup.auth.api")
@@ -31,6 +37,18 @@ class RegisterRequest(BaseModel):
password: str = Field(min_length=8, max_length=128)
code: str = Field(min_length=6, max_length=6, description="邮箱验证码")
nickname: str | None = Field(default=None, max_length=50)
+ invite_code: str | None = Field(
+ default=None,
+ max_length=16,
+ description="邀请链接中的邀请码,选填;有则发双方邀请奖励",
+ )
+
+ @field_validator("invite_code", mode="before")
+ @classmethod
+ def blank_invite_code(cls, value: object) -> object:
+ if isinstance(value, str) and not value.strip():
+ return None
+ return value
class LoginRequest(BaseModel):
@@ -77,7 +95,9 @@ class ResetPasswordRequest(BaseModel):
"""重置密码请求(忘记密码场景)。"""
email: EmailStr
- code: str = Field(min_length=6, max_length=6, description="reset_password 用途的验证码")
+ code: str = Field(
+ min_length=6, max_length=6, description="reset_password 用途的验证码"
+ )
new_password: str = Field(min_length=8, max_length=128)
@@ -111,10 +131,16 @@ class UserOut(BaseModel):
@router.post("/register", response_model=Response[TokenResponse])
def register(body: RegisterRequest, session: Session = Depends(get_session)):
- """邮箱+验证码+密码注册,注册即登录。"""
- result = service.register_by_email_with_session(
+ """邮箱+验证码+密码注册。邀请码选填。"""
+ result = service.register_by_email(
session,
- type("RegisterInput", (), {"email": body.email, "password": body.password, "code": body.code, "nickname": body.nickname})(),
+ RegisterInput(
+ email=body.email,
+ password=body.password,
+ code=body.code,
+ nickname=body.nickname,
+ invite_code=body.invite_code,
+ ),
)
return Response.success(
TokenResponse(
@@ -129,9 +155,11 @@ def register(body: RegisterRequest, session: Session = Depends(get_session)):
@router.post("/login", response_model=Response[TokenResponse])
def login(body: LoginRequest, session: Session = Depends(get_session)):
"""邮箱+密码+验证码登录。"""
- result = service.login_by_password_with_session(
+ result = service.login_by_password(
session,
- type("LoginByPasswordInput", (), {"email": body.email, "password": body.password})(),
+ type(
+ "LoginByPasswordInput", (), {"email": body.email, "password": body.password}
+ )(),
)
return Response.success(
TokenResponse(
@@ -152,8 +180,8 @@ def send_code(body: SendCodeRequest):
@router.post("/login-by-code", response_model=Response[TokenResponse])
def login_by_code(body: LoginByCodeRequest, session: Session = Depends(get_session)):
- """验证码登录,无账号自动注册。"""
- result = service.login_by_code_with_session(
+ """验证码登录。未知邮箱自动建号并赠送注册积分。"""
+ result = service.login_by_code(
session,
type("LoginByCodeInput", (), {"email": body.email, "code": body.code})(),
)
@@ -195,26 +223,37 @@ def get_me(request: Request, session: Session = Depends(get_session)):
if user is None:
from windup_common.enums.biz_code import BizCode
from windup_common.exceptions import BizException
+
raise BizException("用户不存在", code=BizCode.NOT_FOUND)
return Response.success(
UserOut(
id=user.id,
email=user.email,
nickname=user.nickname,
- email_verified_at=user.email_verified_at.isoformat() if user.email_verified_at else None,
+ email_verified_at=user.email_verified_at.isoformat()
+ if user.email_verified_at
+ else None,
status=user.status,
)
)
@router.post("/change-password", response_model=Response[None])
-def change_password(body: ChangePasswordRequest, request: Request, session: Session = Depends(get_session)):
+def change_password(
+ body: ChangePasswordRequest,
+ request: Request,
+ session: Session = Depends(get_session),
+):
"""修改密码。"""
current_user = request.state.current_user
- service.change_password_with_session(
+ service.change_password(
session,
current_user.id,
- type("ChangePasswordInput", (), {"old_password": body.old_password, "new_password": body.new_password})(),
+ type(
+ "ChangePasswordInput",
+ (),
+ {"old_password": body.old_password, "new_password": body.new_password},
+ )(),
)
return Response.success(None, message="密码修改成功")
@@ -222,18 +261,24 @@ def change_password(body: ChangePasswordRequest, request: Request, session: Sess
@router.post("/reset-password", response_model=Response[None])
def reset_password(body: ResetPasswordRequest, session: Session = Depends(get_session)):
"""邮箱+验证码重置密码(忘记密码)。"""
- service.reset_password_with_session(
+ service.reset_password(
session,
- ResetPasswordInput(email=body.email, code=body.code, new_password=body.new_password),
+ ResetPasswordInput(
+ email=body.email, code=body.code, new_password=body.new_password
+ ),
)
return Response.success(None, message="密码重置成功")
@router.patch("/profile", response_model=Response[UserOut])
-def update_nickname(body: UpdateNicknameRequest, request: Request, session: Session = Depends(get_session)):
+def update_nickname(
+ body: UpdateNicknameRequest,
+ request: Request,
+ session: Session = Depends(get_session),
+):
"""修改当前用户昵称。"""
current_user = request.state.current_user
- user_view = service.update_nickname_with_session(
+ user_view = service.update_nickname(
session, current_user.id, UpdateNicknameInput(nickname=body.nickname)
)
return Response.success(
@@ -241,7 +286,9 @@ def update_nickname(body: UpdateNicknameRequest, request: Request, session: Sess
id=user_view.id,
email=user_view.email,
nickname=user_view.nickname,
- email_verified_at=user_view.email_verified_at.isoformat() if user_view.email_verified_at else None,
+ email_verified_at=user_view.email_verified_at.isoformat()
+ if user_view.email_verified_at
+ else None,
status=user_view.status,
),
message="昵称修改成功",
diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py
index e125f4d6..42f2efd6 100644
--- a/backend/packages/app/src/windup_app/web/api/character.py
+++ b/backend/packages/app/src/windup_app/web/api/character.py
@@ -4,9 +4,11 @@
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from windup_common.enums.biz_code import BizCode
+from windup_common.enums.character import CharacterStatus
from windup_common.exceptions import BizException
from windup_common.result import ListResponse, Response
from windup_framework.config.storage import settings as storage_settings
@@ -16,6 +18,7 @@
from windup_app.server.character.service import service as character_service
from windup_app.server.media.service import service as media_service
from windup_app.server.project.model import Project
+from windup_app.server.project.service import service as project_service
logger = logging.getLogger("windup.character.api")
@@ -95,16 +98,19 @@ def _extract_object_keys(character: Character) -> list[str]:
def _get_project_or_raise(
- session: Session, project_id: int, user_id: int,
+ session: Session, project_id: int, user_id: int, *, for_update: bool = False,
) -> Project:
- """校验项目存在且属于当前用户,否则抛 BizException。"""
- project = session.get(Project, project_id)
+ """校验项目存在且属于当前用户,否则抛 BizException。
+
+ 创建角色时 ``for_update=True``,与删除项目锁同一行,避免并发下留下无归属角色。
+ """
+ project = project_service.get_project(session, project_id, for_update=for_update)
if project is None or project.user_id != user_id:
raise BizException("项目不存在", code=BizCode.NOT_FOUND)
return project
-def _get_character_with_auth(
+def get_character_with_auth(
session: Session, character_id: int, user_id: int,
) -> Character:
"""获取角色并校验其所属项目属于当前用户。
@@ -130,16 +136,31 @@ def create_character(
session: Session = Depends(get_session),
) -> Response[CharacterOut]:
user_id = request.state.current_user.id
- _get_project_or_raise(session, body.project_id, user_id)
- character = character_service.create_character(
- session,
- project_id=body.project_id,
- workflow_run_id=body.workflow_run_id,
- name=body.name,
- description=body.description,
- reference_image_url=body.reference_image_url,
- character_data=body.character_data.model_dump(),
- )
+ _get_project_or_raise(session, body.project_id, user_id, for_update=True)
+ character_data = body.character_data.model_dump()
+ status = CharacterStatus.from_character_data(character_data)
+ try:
+ character = character_service.create_character(
+ session,
+ project_id=body.project_id,
+ workflow_run_id=body.workflow_run_id,
+ name=body.name,
+ description=body.description,
+ reference_image_url=body.reference_image_url,
+ character_data=character_data,
+ status=status,
+ )
+ except IntegrityError as exc:
+ session.rollback()
+ orig = str(getattr(exc, "orig", exc)).lower()
+ if "foreign key" in orig:
+ raise BizException("项目不存在", code=BizCode.NOT_FOUND) from None
+ character = character_service.get_character_by_workflow_run(
+ session,
+ body.workflow_run_id,
+ )
+ if character is None or character.project_id != body.project_id:
+ raise BizException("角色不存在", code=BizCode.NOT_FOUND) from None
return Response.success(CharacterOut.model_validate(character), message="创建成功")
@@ -149,12 +170,13 @@ def list_characters(
request: Request = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
+ status: int | None = Query(None, ge=0, le=1, description="按发布状态过滤: 0=草稿, 1=已发布"),
session: Session = Depends(get_session),
) -> ListResponse[CharacterOut]:
user_id = request.state.current_user.id
_get_project_or_raise(session, project_id, user_id)
items, total = character_service.list_characters(
- session, project_id=project_id, page=page, page_size=page_size,
+ session, project_id=project_id, page=page, page_size=page_size, status=status,
)
return ListResponse.success(
[CharacterOut.model_validate(c) for c in items],
@@ -171,7 +193,7 @@ def get_character(
session: Session = Depends(get_session),
) -> Response[CharacterOut]:
user_id = request.state.current_user.id
- character = _get_character_with_auth(session, character_id, user_id)
+ character = get_character_with_auth(session, character_id, user_id)
return Response.success(CharacterOut.model_validate(character))
@@ -183,8 +205,16 @@ def update_character(
session: Session = Depends(get_session),
) -> Response[CharacterOut]:
user_id = request.state.current_user.id
- _get_character_with_auth(session, character_id, user_id)
+ get_character_with_auth(session, character_id, user_id)
fields = body.model_dump(exclude_unset=True)
+ # 如果更新了 character_data,自动推断 status
+ if "character_data" in fields:
+ if fields["character_data"] is None:
+ raise BizException("character_data 不能为 null", code=BizCode.BAD_REQUEST)
+ character_data = fields["character_data"]
+ if isinstance(character_data, CharacterData):
+ character_data = character_data.model_dump()
+ fields["status"] = CharacterStatus.from_character_data(character_data)
character = character_service.update_character(session, character_id, **fields)
if character is None:
raise BizException("角色不存在", code=BizCode.NOT_FOUND)
@@ -198,7 +228,7 @@ def delete_character(
session: Session = Depends(get_session),
) -> Response[None]:
user_id = request.state.current_user.id
- character = _get_character_with_auth(session, character_id, user_id)
+ character = get_character_with_auth(session, character_id, user_id)
# 先提取对象 key,再删 DB 记录
object_keys = _extract_object_keys(character)
diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py
index a3f80ca3..a000afa6 100644
--- a/backend/packages/app/src/windup_app/web/api/generation.py
+++ b/backend/packages/app/src/windup_app/web/api/generation.py
@@ -21,7 +21,8 @@
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import StreamingResponse
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
+from sqlalchemy import event
from sqlalchemy.orm import Session
from windup_common.enums.biz_code import BizCode
@@ -29,10 +30,17 @@
from windup_common.result import Response
from windup_framework.db import get_session
+from windup_app.server.character.model import Character, CharacterData
+from windup_app.server.orchestrator import task_repo
+from windup_app.server.orchestrator.dispatcher import GenerationDispatcher
+from windup_app.server.orchestrator.service import service as generation_service
from windup_app.server.orchestrator.model import (
ActionType,
+ CharacterActionInput,
+ CharacterImageInput,
GenerationTask,
)
+from windup_app.server.project.model import Project
logger = logging.getLogger("windup.generation.api")
@@ -51,27 +59,76 @@
class _EventBus:
- """任务进度内存发布-订阅。"""
+ """任务进度内存发布-订阅。
+
+ **publish 会被后台线程调用**(executor 在生成工作线程里跑,经 task_repo 触发),
+ 而队列属于处理 SSE 请求的那个 event loop。``asyncio.Queue`` 不是线程安全的:
+ 跨线程 ``put_nowait`` 能把元素放进去,但唤醒 waiter 用的是 loop 内部调度,
+ 从别的线程调不会唤醒 —— 订阅者可能一直挂在 ``get()`` 上,直到下一次同 loop 内的
+ 操作偶然把它带起来。故订阅时记下所属 loop,发布时经 ``call_soon_threadsafe``
+ 回到那个 loop 上再入队(2026-08-10 机器审逮到)。
+ """
def __init__(self) -> None:
- self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list)
-
- async def subscribe(self, task_id: int) -> asyncio.Queue:
+ # 键是 (project_id, task_id):同一个 task_id 在不同项目下互不串流(主线 #110)。
+ # 值是 (queue, 它所属的 loop):不同订阅者可能来自不同 loop(多 worker / 测试里的
+ # 临时 loop),不能只存一个全局 loop —— 见 publish 里的 call_soon_threadsafe。
+ self._queues: dict[
+ tuple[int, int], list[tuple[asyncio.Queue, asyncio.AbstractEventLoop]]
+ ] = defaultdict(list)
+
+ async def subscribe(self, project_id: int, task_id: int) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue()
- self._queues[str(task_id)].append(queue)
+ self._queues[(project_id, task_id)].append((queue, asyncio.get_running_loop()))
return queue
- async def unsubscribe(self, task_id: int, queue: asyncio.Queue) -> None:
- key = str(task_id)
+ async def unsubscribe(
+ self,
+ project_id: int,
+ task_id: int,
+ queue: asyncio.Queue,
+ ) -> None:
+ key = (project_id, task_id)
subs = self._queues.get(key)
- if subs and queue in subs:
- subs.remove(queue)
- if not subs:
- del self._queues[key]
-
- def publish(self, task_id: int, event: str, data: dict) -> None:
- for queue in self._queues.get(str(task_id), []):
- queue.put_nowait((event, data))
+ if not subs:
+ return
+ self._queues[key] = [(q, lp) for q, lp in subs if q is not queue]
+ if not self._queues[key]:
+ del self._queues[key]
+
+ def publish(
+ self,
+ project_id: int,
+ task_id: int,
+ event: str,
+ data: dict,
+ ) -> None:
+ """跨线程安全地投递。
+
+ executor 在生成工作线程里跑,而队列属于处理 SSE 请求的那个 event loop。
+ ``asyncio.Queue`` 不是线程安全的:跨线程 ``put_nowait`` 能把元素放进去,但唤醒
+ waiter 用的是 loop 内部调度,从别的线程调不会唤醒 —— 订阅者可能一直挂在
+ ``get()`` 上,直到下一次同 loop 内的操作偶然把它带起来。故订阅时记下所属 loop,
+ 发布时经 ``call_soon_threadsafe`` 回到那个 loop 上再入队。
+ """
+ try:
+ here = asyncio.get_running_loop()
+ except RuntimeError:
+ here = None # 从没有 loop 的生成工作线程调用
+
+ for queue, loop in list(self._queues.get((project_id, task_id), [])):
+ if loop is here:
+ # 同一个 loop 内:直接入队。**不能一律走 call_soon_threadsafe** —— 那是
+ # 异步调度,要等 loop 下一次迭代才真入队,于是"publish 完立刻 get_nowait"
+ # 会拿到空队列(主线 #110 的隔离用例正是这么写的)。
+ queue.put_nowait((event, data))
+ continue
+ try:
+ loop.call_soon_threadsafe(queue.put_nowait, (event, data))
+ except RuntimeError:
+ # loop 已关闭(客户端断连后请求 loop 结束)。丢弃即可 —— 没有订阅者在等
+ # 这条消息,而任务状态本身已落库,重连后靠 GET /tasks/{id} 取。
+ logger.debug("SSE loop 已关闭,丢弃事件 task_id=%d event=%s", task_id, event)
# 全局实例,挂到 app.state.event_bus
@@ -86,27 +143,58 @@ def publish(self, task_id: int, event: str, data: dict) -> None:
class CharacterImageGenerateRequest(BaseModel):
"""提交角色图片生成任务。"""
- user_id: int = Field(gt=0)
- project_id: int | None = None
+ # project_id 必填,它是归属校验的依据(见 _get_project_or_raise)。
+ # 注:曾有 `user_id: int = Field(gt=0)`。归属者从 request.state.current_user 取,
+ # 请求体里那个字段既不被读、又让调用方以为自己能指定归属者 —— 填别人的 id 不报错
+ # 也不生效,正是本仓最忌讳的"看起来生效的错"。已删。
+ project_id: int = Field(gt=0)
reference_image_url: str | None = None
prompt: str = ""
negative_prompt: str = ""
- width: int = 1024
- height: int = 1024
- num_images: int = 1
+ # 三个上界都直通付费调用,必须在契约层卡住:num_images 是 provider 调用次数的
+ # 循环上界,一个已认证请求填个大数就能绕过按请求计的限流、把成本拉到无上限
+ # (2026-08-10 机器审逮到)。宽高上界按当前 i2v 与像素化管线的实际处理范围取。
+ width: int = Field(default=1024, ge=64, le=2048)
+ height: int = Field(default=1024, ge=64, le=2048)
+ num_images: int = Field(default=1, ge=1, le=4)
class CharacterActionGenerateRequest(BaseModel):
"""提交角色动作生成任务。"""
- user_id: int = Field(gt=0)
- project_id: int | None = None
+ # project_id 必填,它是归属校验的依据(见 _get_project_or_raise)。
+ # 注:曾有 `user_id: int = Field(gt=0)`。归属者从 request.state.current_user 取,
+ # 请求体里那个字段既不被读、又让调用方以为自己能指定归属者 —— 填别人的 id 不报错
+ # 也不生效,正是本仓最忌讳的"看起来生效的错"。已删。
+ project_id: int = Field(gt=0)
character_id: int = Field(gt=0)
action_type: ActionType
custom_prompt: str | None = None
reference_video_url: str | None = None
reference_image_urls: list[str] = Field(default_factory=list)
- num_frames: int = 16
+ # 同上:帧数决定抽帧与逐帧抠图的工作量,上界 64 已远超引擎能出的有效周期长度。
+ num_frames: int = Field(default=16, ge=1, le=64)
+ # ── action_type=custom 才用到(#239)───────────────────────────────────
+ # 这个动作是否循环播放。不给则编排层兜成一次性,也不按描述文字猜 —— 两个方向的代价
+ # 不对称:一次性动作被当成循环会让末帧接回首帧抽搐、产物不可用,反之只是不无缝闭环、
+ # 仍可用。而且猜错是静默的,帧数/时长/成色全部正常、没有任何一道会红。
+ loop: bool | None = None
+ # 视频模型。None = 用部署默认(kling-v2-5-turbo)。取值域见
+ # orchestrator.executor.ALLOWED_VIDEO_MODELS;非法值在入口就报错,不到付费调用才失败。
+ video_model: str | None = None
+ # 这次动作属于哪个造型。给了才可能走三渲二 —— 3D 资产挂在造型一级(#121)。
+ # 不给则照旧走 i2v(向后兼容:前端接上之前所有调用都是这样)。
+ # 让**所有**动作生成都按造型定位外观是 #253,不在本改动范围内。
+ outfit_id: str | None = None
+
+ @model_validator(mode="after")
+ def require_custom_prompt(self):
+ if self.action_type is ActionType.CUSTOM:
+ prompt = (self.custom_prompt or "").strip()
+ if not prompt:
+ raise ValueError("custom 动作必须提供 custom_prompt")
+ self.custom_prompt = prompt
+ return self
class GenerationTaskOut(BaseModel):
@@ -115,7 +203,6 @@ class GenerationTaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
- user_id: int
project_id: int | None = None
task_type: str
status: str
@@ -131,7 +218,6 @@ def _task_to_out(task: GenerationTask) -> GenerationTaskOut:
result_dict = dataclasses.asdict(task.result)
return GenerationTaskOut(
id=task.id,
- user_id=task.user_id,
project_id=task.project_id,
task_type=task.task_type.value,
status=task.status.value,
@@ -146,15 +232,55 @@ def _task_to_out(task: GenerationTask) -> GenerationTaskOut:
# ══════════════════════════════════════════════════════════════════════════════
-def _validate_project_size(session: Session, project_id: int | None, width: int, height: int) -> None:
+def _get_project_or_raise(
+ session: Session,
+ project_id: int,
+ user_id: int,
+) -> Project:
+ """校验项目存在且属于 token 对应用户。"""
+ project = session.get(Project, project_id)
+ if project is None or project.user_id != user_id:
+ raise BizException("项目不存在", code=BizCode.NOT_FOUND)
+ return project
+
+
+def _get_character_or_raise(
+ session: Session,
+ character_id: int,
+ project_id: int,
+) -> Character:
+ """校验角色存在且属于本次生成所指定的项目。"""
+ character = session.get(Character, character_id)
+ if character is None or character.project_id != project_id:
+ raise BizException("角色不存在", code=BizCode.NOT_FOUND)
+ return character
+
+
+def _outfit_model_3d_url(character: Character, outfit_id: str | None) -> str | None:
+ """这个造型有没有绑骨 3D 模型 —— **三渲二的唯一判据**(#122)。
+
+ 判据在这里读 DB 而不是做成 ai_engine port 上的查询:引擎只吃 bytes、不碰存储。
+ 没给 ``outfit_id`` 就返回 None 照旧走 i2v,**不猜"那就用第一个造型吧"** —— 猜错
+ 等于拿另一个造型的模型渲这次的动作,角色穿错衣服而帧数、时长、成色全部正常。
+ """
+ if not outfit_id:
+ return None
+ try:
+ data = CharacterData.model_validate(character.character_data or {})
+ except ValidationError:
+ # 结构对不上就当没有资产:这一步只决定"走哪条路线",不该因为 character_data
+ # 里某个无关字段脏了就让整个动作生成起不来。走 i2v 仍然出得了帧。
+ logger.warning("character %s 的 character_data 解析失败,三渲二判据按无资产处理",
+ character.id)
+ return None
+ outfit = next((o for o in data.outfits if o.id == outfit_id), None)
+ if outfit is None:
+ raise BizException(f"造型 {outfit_id!r} 不属于该角色", code=BizCode.NOT_FOUND)
+ return (outfit.model_3d_url or "").strip() or None
+
+
+def _validate_project_size(project: Project, width: int, height: int) -> None:
"""校验输入尺寸与项目约束是否一致;不一致则抛异常。"""
- if project_id is None:
- return
- from windup_app.server.project.service import SqlAlchemyProjectService
-
- project = SqlAlchemyProjectService().get_project(session, project_id)
- if project is None:
- return
if width != project.sprite_width or height != project.sprite_height:
raise BizException(
f"输入尺寸 {width}×{height} 与项目约束 {project.sprite_width}×{project.sprite_height} 不一致",
@@ -162,6 +288,22 @@ def _validate_project_size(session: Session, project_id: int | None, width: int,
)
+def _dispatch_after_commit(
+ session: Session,
+ dispatcher: GenerationDispatcher,
+ target,
+ *args,
+) -> None:
+ """注册 after_commit 回调:session 提交成功后再排入生成队列。
+
+ 解决竞态: create_task() 只 flush,session 在 handler 返回后才 commit。
+ 若直接排队,后台 session 可能读不到未提交的行,导致 update 静默跳过。
+ """
+ @event.listens_for(session, "after_commit", once=True)
+ def _after_commit(session):
+ dispatcher.submit(target, *args)
+
+
@router.post("/image", response_model=Response[GenerationTaskOut])
def submit_image_generation(
body: CharacterImageGenerateRequest,
@@ -169,9 +311,31 @@ def submit_image_generation(
session: Session = Depends(get_session),
) -> Response[GenerationTaskOut]:
"""提交角色图片生成任务:建 PENDING 记录立即返回,实际图生图后台跑。"""
- _validate_project_size(session, body.project_id, body.width, body.height)
- # TODO: service.create_image_task + background_tasks.add_task
- raise BizException("接口待实现", code=BizCode.BAD_REQUEST)
+ user_id = request.state.current_user.id
+ project = _get_project_or_raise(session, body.project_id, user_id)
+ _validate_project_size(project, body.width, body.height)
+ input_data = CharacterImageInput(
+ reference_image_url=body.reference_image_url,
+ prompt=body.prompt,
+ negative_prompt=body.negative_prompt,
+ width=body.width,
+ height=body.height,
+ num_images=body.num_images,
+ )
+ task = generation_service.generate_character_image(
+ session, user_id=user_id, project_id=body.project_id, input=input_data,
+ )
+ # 生成任务要在 commit 之后再入队:任务行未提交时工作线程用自己的 session 读不到它,
+ # update 会静默跳过,表现为任务永远停在 PENDING。
+ _dispatch_after_commit(
+ session,
+ request.app.state.generation_dispatcher,
+ request.app.state.run_image_task,
+ task.id,
+ input_data,
+ body.project_id,
+ )
+ return Response.success(_task_to_out(task), message="任务已提交")
@router.post("/action", response_model=Response[GenerationTaskOut])
@@ -181,19 +345,53 @@ def submit_action_generation(
session: Session = Depends(get_session),
) -> Response[GenerationTaskOut]:
"""提交角色动作生成任务:建 PENDING 记录立即返回,实际生成后台跑。"""
- # TODO: service.create_action_task + background_tasks.add_task
- raise BizException("接口待实现", code=BizCode.BAD_REQUEST)
+ user_id = request.state.current_user.id
+ _get_project_or_raise(session, body.project_id, user_id)
+ character = _get_character_or_raise(session, body.character_id, body.project_id)
+ input_data = CharacterActionInput(
+ character_id=body.character_id,
+ action_type=body.action_type,
+ custom_prompt=body.custom_prompt,
+ loop=body.loop,
+ video_model=body.video_model,
+ reference_video_url=body.reference_video_url,
+ reference_image_urls=body.reference_image_urls,
+ num_frames=body.num_frames,
+ outfit_id=body.outfit_id,
+ # 路线选择在这里定死并写进入参,而不是留给编排层现查:这样"这次走的哪条路线"
+ # 在任务入参上就是可见的,排查时不用去猜当时 DB 是什么状态。
+ model_3d_url=_outfit_model_3d_url(character, body.outfit_id),
+ )
+ task = generation_service.generate_character_action(
+ session, user_id=user_id, project_id=body.project_id, input=input_data,
+ )
+ _dispatch_after_commit(
+ session,
+ request.app.state.generation_dispatcher,
+ request.app.state.run_action_task,
+ task.id,
+ input_data,
+ body.project_id,
+ )
+ return Response.success(_task_to_out(task), message="任务已提交")
@router.get("/tasks/{task_id}", response_model=Response[GenerationTaskOut])
def get_task(
task_id: int,
project_id: int = Query(..., gt=0),
+ request: Request = None,
session: Session = Depends(get_session),
) -> Response[GenerationTaskOut]:
"""查询生成任务状态与结果。"""
- # TODO: service.get_task
- raise BizException("接口待实现", code=BizCode.BAD_REQUEST)
+ user_id = request.state.current_user.id
+ _get_project_or_raise(session, project_id, user_id)
+ task = task_repo.get_task(session, task_id)
+ if task is None or task.project_id != project_id:
+ # 归属两道,与 stream_task 同口径:只查项目不够,任意已认证用户拿自己的
+ # project_id 配上别人的 task_id 就能读到别人的产物 URL。
+ raise BizException("任务不存在", code=BizCode.NOT_FOUND)
+ return Response.success(_task_to_out(task))
@router.get("/tasks/{task_id}/stream")
@@ -211,20 +409,41 @@ async def stream_task(
- ``failed``: 任务失败,携带错误信息
若客户端订阅时任务已处于终态,立即推送终态事件并关闭连接。
+
+ 归属是**两道**(2026-08-11 补齐,此前是一行 TODO):项目要属于当前用户
+ (``_get_project_or_raise``),任务还要属于那个项目。只查项目不够 —— 任意已认证用户
+ 拿自己的 project_id 配上别人的 task_id 就能订阅到别人的流,而事件体里带 result,
+ 即最终帧的对象存储 URL。两道都必须在 ``subscribe`` **之前**:放之后的话越权请求仍会
+ 在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
+ unsubscribe 而增长。
"""
- # TODO: 检查任务初始状态,若已终态立即推送
- queue = await event_bus.subscribe(task_id)
- logger.debug("SSE 订阅: task_id=%d", task_id)
+ user_id = request.state.current_user.id
+ _get_project_or_raise(session, project_id, user_id)
+ task = task_repo.get_task(session, task_id)
+ if task is None or task.project_id != project_id:
+ raise BizException("任务不存在", code=BizCode.NOT_FOUND)
+
+ # 终态快照要在订阅前读,订阅要紧跟其后 —— 两者之间若任务刚好终结,事件会丢。
+ # 反过来(先订阅后读)则会重复发一次终态,客户端拿到两条 completed。
+ terminal_event = task_repo.terminal_event_for(task)
+
+ queue = await event_bus.subscribe(project_id, task_id)
+ logger.debug("SSE 订阅: task_id=%d project_id=%d", task_id, project_id)
async def _event_generator():
try:
+ if terminal_event is not None:
+ payload = json.dumps(task_repo.task_event_payload(task), ensure_ascii=False)
+ yield f"event: {terminal_event}\ndata: {payload}\n\n"
+ return
while True:
if await request.is_disconnected():
logger.debug("SSE 客户端断开: task_id=%d", task_id)
break
try:
event, data = await asyncio.wait_for(
- queue.get(), timeout=_HEARTBEAT_TIMEOUT,
+ queue.get(),
+ timeout=_HEARTBEAT_TIMEOUT,
)
payload = json.dumps(data, ensure_ascii=False)
yield f"event: {event}\ndata: {payload}\n\n"
@@ -234,7 +453,7 @@ async def _event_generator():
except asyncio.TimeoutError:
yield ": heartbeat\n\n"
finally:
- await event_bus.unsubscribe(task_id, queue)
+ await event_bus.unsubscribe(project_id, task_id, queue)
logger.debug("SSE 取消订阅: task_id=%d", task_id)
return StreamingResponse(
diff --git a/backend/packages/app/src/windup_app/web/api/media.py b/backend/packages/app/src/windup_app/web/api/media.py
index ad464af5..355db2aa 100644
--- a/backend/packages/app/src/windup_app/web/api/media.py
+++ b/backend/packages/app/src/windup_app/web/api/media.py
@@ -1,5 +1,7 @@
"""媒体文件上传 API。"""
+import asyncio
+
from fastapi import APIRouter, File, UploadFile
from windup_common.enums.biz_code import BizCode
@@ -11,6 +13,58 @@
router = APIRouter(prefix="/media", tags=["media"])
+# 允许的 MIME 类型白名单(精确匹配,不接受通配符子类型)
+_ALLOWED_IMAGE_TYPES: set[str] = {
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+}
+_ALLOWED_MODEL_TYPES: set[str] = {
+ "model/gltf-binary",
+ "model/gltf+json",
+}
+_ALLOWED_TYPES = _ALLOWED_IMAGE_TYPES | _ALLOWED_MODEL_TYPES
+
+# 大小限制(按类型分组)
+_IMAGE_SIZE_LIMIT = 10 * 1024 * 1024 # 10 MB
+_MODEL_SIZE_LIMIT = 80 * 1024 * 1024 # 80 MB
+
+# 图片 magic bytes 校验表
+# WebP 需要额外检查 bytes 8-11 为 "WEBP",单独处理
+_IMAGE_SIGNATURES: dict[str, bytes] = {
+ "image/png": b"\x89PNG\r\n\x1a\n",
+ "image/jpeg": b"\xff\xd8\xff",
+ "image/gif": b"GIF8", # GIF87a 和 GIF89a 都以 GIF8 开头
+}
+_WEBP_RIFF_PREFIX = b"RIFF"
+_WEBP_MARKER = b"WEBP" # bytes 8-11
+
+
+def _get_size_limit(content_type: str) -> int:
+ """根据 content_type 返回对应的大小限制。"""
+ if content_type in _ALLOWED_MODEL_TYPES:
+ return _MODEL_SIZE_LIMIT
+ return _IMAGE_SIZE_LIMIT
+
+
+def _validate_image_magic(data: bytes, content_type: str) -> bool:
+ """校验文件头 magic bytes 是否与声明的 content_type 匹配。
+
+ 仅允许 _ALLOWED_IMAGE_TYPES 中的类型,未知类型直接拒绝。
+ """
+ # WebP 需要同时检查 RIFF 前缀和 WEBP 标记(bytes 8-11)
+ if content_type == "image/webp":
+ return (
+ len(data) >= 12
+ and data[:4] == _WEBP_RIFF_PREFIX
+ and data[8:12] == _WEBP_MARKER
+ )
+ expected = _IMAGE_SIGNATURES.get(content_type)
+ if expected is None:
+ return False
+ return data[: len(expected)] == expected
+
@router.post("/upload", response_model=Response[MediaUploadResult])
async def upload_media(
@@ -18,15 +72,44 @@ async def upload_media(
category: MediaCategory = MediaCategory.GENERAL,
) -> Response[MediaUploadResult]:
"""接收前端文件并上传对象存储,返回 URL。"""
- if not file.content_type or not file.content_type.startswith("image/"):
- raise BizException("仅支持图片文件", code=BizCode.BAD_REQUEST)
+ if not file.content_type:
+ raise BizException("缺少 content_type", code=BizCode.BAD_REQUEST)
+
+ if file.content_type not in _ALLOWED_TYPES:
+ raise BizException(
+ f"不支持的文件类型: {file.content_type}",
+ code=BizCode.BAD_REQUEST,
+ )
+
+ # 根据 content_type 确定大小限制
+ size_limit = _get_size_limit(file.content_type)
+
+ # 分块读取并校验大小,避免一次性读入大文件导致 OOM
+ # 使用 bytearray 原地扩展,避免 chunks 列表 + join 的双倍内存开销
+ data = bytearray()
+ while True:
+ chunk = await file.read(64 * 1024) # 64 KB per chunk
+ if not chunk:
+ break
+ if len(data) + len(chunk) > size_limit:
+ raise BizException(
+ f"文件大小超过限制(最大 {size_limit // 1024 // 1024} MB)",
+ code=BizCode.BAD_REQUEST,
+ )
+ data.extend(chunk)
+
+ # 图片类型做 magic bytes 校验
+ if file.content_type in _ALLOWED_IMAGE_TYPES:
+ if not _validate_image_magic(data, file.content_type):
+ raise BizException("文件内容与声明的类型不匹配", code=BizCode.BAD_REQUEST)
- data = await file.read()
metadata = MediaUploadInput(
filename=file.filename or "upload",
content_type=file.content_type,
size=len(data),
category=category,
)
- result = service.upload(data, metadata)
+
+ # 同步上传放到线程池,避免阻塞事件循环
+ result = await asyncio.to_thread(service.upload, data, metadata)
return Response.success(result)
diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py
index ead71016..a51af31d 100644
--- a/backend/packages/app/src/windup_app/web/api/project.py
+++ b/backend/packages/app/src/windup_app/web/api/project.py
@@ -13,6 +13,7 @@
from windup_common.result import ListResponse, Response
from windup_framework.db import get_session
+from windup_app.server.character.service import service as character_service
from windup_app.server.project.service import service
logger = logging.getLogger("windup.project.api")
@@ -39,7 +40,6 @@ class ProjectOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
- user_id: int
workflow_id: int | None
project_name: str
character_perspective: int
@@ -64,7 +64,8 @@ def create_project(
):
logger.warning(
"[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s",
- user_id, body.project_name,
+ user_id,
+ body.project_name,
)
raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST)
try:
@@ -72,7 +73,8 @@ def create_project(
except IntegrityError:
logger.warning(
"[WINDUP] 创建拒绝-并发冲突 | user_id=%s project_name=%s",
- user_id, body.project_name,
+ user_id,
+ body.project_name,
)
session.rollback()
raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None
@@ -116,8 +118,14 @@ def delete_project(
request: Request,
session: Session = Depends(get_session),
) -> Response[None]:
- project = service.get_project(session, project_id)
+ project = service.get_project(session, project_id, for_update=True)
if project is None or project.user_id != request.state.current_user.id:
raise BizException("项目不存在", code=BizCode.NOT_FOUND)
- service.delete_project(session, project_id)
+ if character_service.project_has_characters(session, project_id):
+ raise BizException("项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST)
+ try:
+ service.delete_project(session, project_id)
+ except IntegrityError:
+ session.rollback()
+ raise BizException("项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST) from None
return Response.success(None, message="删除成功")
diff --git a/backend/packages/app/src/windup_app/web/api/quota.py b/backend/packages/app/src/windup_app/web/api/quota.py
new file mode 100644
index 00000000..85e6c257
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/quota.py
@@ -0,0 +1,146 @@
+"""积分模块 API。
+
+端点一览
+--------
+GET /quota/balance 查询积分余额
+GET /quota/transactions 查询积分流水(分页)
+GET /quota/invite/code 获取我的邀请码
+POST /quota/invite/generate 签发新邀请码
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, Query, Request
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy.orm import Session
+
+from windup_common.result import ListResponse, Response
+from windup_framework.db import get_session
+
+from windup_app.server.quota.service import service
+
+logger = logging.getLogger("windup.quota.api")
+
+router = APIRouter(prefix="/quota", tags=["quota"])
+
+
+# -- 响应模型 --------------------------------------------------------------
+
+
+class CreditAccountOut(BaseModel):
+ """积分账户响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ user_id: int
+ balance: int
+ frozen: int
+ total_earned: int
+ total_spent: int
+ create_at: datetime
+ update_at: datetime
+
+
+class CreditTransactionOut(BaseModel):
+ """积分流水响应。"""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ user_id: int
+ delta: int
+ reason: int
+ billing_mode: int
+ ref_id: str | None
+ balance_after: int
+ create_at: datetime
+
+
+class InviteCodeOut(BaseModel):
+ """邀请码响应。"""
+
+ code: str
+ used_count: int
+ expires_at: datetime
+ create_at: datetime
+ update_at: datetime
+
+
+# -- 端点 ----------------------------------------------------------------
+
+
+@router.get("/balance", response_model=Response[CreditAccountOut])
+def get_balance(
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[CreditAccountOut]:
+ """查询当前用户积分余额。"""
+ user_id = request.state.current_user.id
+ account = service.get_account(session, user_id)
+ if account is None:
+ from windup_common.enums.biz_code import BizCode
+ from windup_common.exceptions import BizException
+
+ raise BizException("积分账户不存在", code=BizCode.NOT_FOUND)
+ return Response.success(CreditAccountOut.model_validate(account))
+
+
+@router.get("/transactions", response_model=ListResponse[CreditTransactionOut])
+def list_transactions(
+ request: Request,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ session: Session = Depends(get_session),
+) -> ListResponse[CreditTransactionOut]:
+ """查询积分流水(分页)。"""
+ user_id = request.state.current_user.id
+ txns, total = service.list_transactions(
+ session, user_id, page=page, page_size=page_size
+ )
+ return ListResponse.success(
+ [CreditTransactionOut.model_validate(t) for t in txns],
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
+
+
+@router.get("/invite/code", response_model=Response[InviteCodeOut])
+def get_invite_code(
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[InviteCodeOut]:
+ """获取当前用户未过期邀请码;没有或已过期则签发新行。"""
+ view = service.get_invite_code(session, request.state.current_user.id)
+ return Response.success(
+ InviteCodeOut(
+ code=view.code,
+ used_count=view.used_count,
+ expires_at=view.expires_at,
+ create_at=view.create_at,
+ update_at=view.update_at,
+ )
+ )
+
+
+@router.post("/invite/generate", response_model=Response[InviteCodeOut])
+def generate_invite_code(
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[InviteCodeOut]:
+ """签发新邀请码。旧码立即过期,行保留。"""
+ view = service.generate_invite_code(session, request.state.current_user.id)
+ return Response.success(
+ InviteCodeOut(
+ code=view.code,
+ used_count=view.used_count,
+ expires_at=view.expires_at,
+ create_at=view.create_at,
+ update_at=view.update_at,
+ ),
+ message="邀请码已更新",
+ )
diff --git a/backend/packages/app/src/windup_app/web/api/render3d.py b/backend/packages/app/src/windup_app/web/api/render3d.py
new file mode 100644
index 00000000..4d06752c
--- /dev/null
+++ b/backend/packages/app/src/windup_app/web/api/render3d.py
@@ -0,0 +1,196 @@
+"""母版预检与造型级 3D 资产的端点 —— 前端那道"确认 → 建 → 审"闸的后端一侧。
+
+**本模块不 import ai_engine,也不 import 任何会牵出它的 server 模块**(门禁
+"入口层不经 ai_engine 直连"是传递性的)。两件事都经 ``request.app.state`` 上的
+运行期注入拿到,与 ``executor`` 走的是同一条路;bootstrap 是唯一的装配点。
+
+代价是这里拿到的是 ``dict`` 而不是带类型的对象,响应模型只能在本文件重写一遍。
+这是刻意的:为了标注类型去 import 那边,门禁当场就红。
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from windup_common.enums.biz_code import BizCode
+from windup_common.exceptions import BizException
+from windup_common.result import Response
+from windup_framework.db import get_session
+
+from windup_app.server.character.model import Character, CharacterData
+from windup_app.server.character.service import service as character_service
+from windup_app.web.api.character import get_character_with_auth
+
+logger = logging.getLogger("windup.render3d.api")
+
+router = APIRouter(prefix="/render3d", tags=["render3d"])
+
+
+class MasterPrecheckRequest(BaseModel):
+ """要预检的母版。只收自家对象存储的 URL —— 服务端替调用方拉任意地址等于把服务器
+ 当跳板,见 ``orchestrator._fetch``。"""
+
+ image_url: str = Field(..., min_length=1)
+ canvas_width: int | None = Field(default=None, gt=0)
+ canvas_height: int | None = Field(default=None, gt=0)
+
+
+def _operations(request: Request):
+ """建资产的四个动作。没装配就明说,别让端点抛 AttributeError。"""
+ operations = getattr(request.app.state, "render3d_operations", None)
+ if operations is None:
+ raise BizException("三渲二资产服务未装配", code=BizCode.INTERNAL_ERROR)
+ return operations
+
+
+def _precheck(request: Request):
+ precheck = getattr(request.app.state, "precheck_master", None)
+ if precheck is None:
+ raise BizException("母版预检服务未装配", code=BizCode.INTERNAL_ERROR)
+ return precheck
+
+
+def _asset_key(character_id: int, outfit_id: str) -> str:
+ """3D 资产落点的键。**必须带上角色 id**:``outfit_id`` 只在所属角色内唯一,
+ 而工作流给首个造型的 id 是写死的 ``outfit-default`` —— 只用它当键,全站每个角色
+ 的默认造型会共用同一个 3D 模型,表现为"别人的角色套着我的模型",且没有任何报错。
+ """
+ return f"character-{character_id}/{outfit_id}"
+
+
+def _outfit_or_raise(character: Character, outfit_id: str) -> dict:
+ for outfit in (character.character_data or {}).get("outfits", []):
+ if outfit.get("id") == outfit_id:
+ return outfit
+ raise BizException("造型不存在", code=BizCode.NOT_FOUND)
+
+
+def _master_url_or_raise(outfit: dict) -> str:
+ """建资产用的母版就是造型的定妆母版。
+
+ 没有它就不能往下走:图生 3D 的入参只有这一张图,拿角色参考图顶替会建出另一个造型
+ 的模型,而接口照常成功、照常扣积分。
+ """
+ url = outfit.get("preview_url")
+ if not url:
+ raise BizException(
+ "该造型还没有已确认的定妆母版,先在工作流里确认母版再建 3D 资产",
+ code=BizCode.BAD_REQUEST,
+ )
+ return url
+
+
+def _sync_model_url(session: Session, character: Character, outfit_id: str, url: str | None) -> None:
+ """把建好的模型 URL 回写到 ``character_data``。
+
+ 回写发生在**读状态**这一步而不是后台线程里:后台线程没有请求作用域的 session,
+ 而三渲二那条路线的判据(``Outfit.model_3d_url``)不回写就永远是 None —— 资产建好了
+ 却依旧显示"该造型暂无绑骨 3D 模型",钱白花。
+ """
+ if not url:
+ return
+ data = CharacterData.model_validate(character.character_data or {})
+ changed = False
+ for outfit in data.outfits:
+ if outfit.id == outfit_id and outfit.model_3d_url != url:
+ outfit.model_3d_url = url
+ changed = True
+ if not changed:
+ return
+ character_service.update_character(session, character.id, character_data=data.model_dump())
+
+
+@router.post("/master-precheck", response_model=Response[dict])
+def precheck_master(
+ body: MasterPrecheckRequest,
+ request: Request,
+) -> Response[dict]:
+ """零成本母版预检。**不产生任何按次计费调用**,可以在确认闸上随便调。"""
+ canvas = (
+ (body.canvas_width, body.canvas_height)
+ if body.canvas_width and body.canvas_height
+ else None
+ )
+ try:
+ report = _precheck(request)(body.image_url, canvas)
+ except ValueError as exc:
+ raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc
+ return Response.success(report)
+
+
+@router.get("/characters/{character_id}/outfits/{outfit_id}", response_model=Response[dict])
+def get_outfit_asset(
+ character_id: int,
+ outfit_id: str,
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[dict]:
+ user_id = request.state.current_user.id
+ character = get_character_with_auth(session, character_id, user_id)
+ _outfit_or_raise(character, outfit_id)
+ view = _operations(request).view(_asset_key(character_id, outfit_id))
+ _sync_model_url(session, character, outfit_id, view["model_3d_url"])
+ return Response.success(view)
+
+
+@router.post("/characters/{character_id}/outfits/{outfit_id}/build", response_model=Response[dict])
+def build_outfit_asset(
+ character_id: int,
+ outfit_id: str,
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[dict]:
+ """启动图生 3D。**按次计费的触发点**,所以只认用户的显式请求,不在任何自动路径上。"""
+ user_id = request.state.current_user.id
+ character = get_character_with_auth(session, character_id, user_id)
+ outfit = _outfit_or_raise(character, outfit_id)
+ operations = _operations(request)
+ try:
+ return Response.success(
+ operations.build(_asset_key(character_id, outfit_id),
+ _master_url_or_raise(outfit)),
+ message="已开始生成 3D 模型",
+ )
+ except ValueError as exc:
+ raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc
+
+
+@router.post("/characters/{character_id}/outfits/{outfit_id}/approve", response_model=Response[dict])
+def approve_outfit_asset(
+ character_id: int,
+ outfit_id: str,
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[dict]:
+ """人看过模型并点头 → 继续绑骨。**唯一的放行入口**,没有超时自动放行。"""
+ user_id = request.state.current_user.id
+ character = get_character_with_auth(session, character_id, user_id)
+ outfit = _outfit_or_raise(character, outfit_id)
+ try:
+ view = _operations(request).approve(
+ _asset_key(character_id, outfit_id), _master_url_or_raise(outfit)
+ )
+ except ValueError as exc:
+ raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc
+ return Response.success(view, message="已放行,开始绑骨")
+
+
+@router.post("/characters/{character_id}/outfits/{outfit_id}/discard", response_model=Response[dict])
+def discard_outfit_asset(
+ character_id: int,
+ outfit_id: str,
+ request: Request,
+ session: Session = Depends(get_session),
+) -> Response[dict]:
+ """模型不合格 → 丢弃重来。混元的模型改不动,这是唯一的补救。"""
+ user_id = request.state.current_user.id
+ character = get_character_with_auth(session, character_id, user_id)
+ _outfit_or_raise(character, outfit_id)
+ try:
+ view = _operations(request).discard(_asset_key(character_id, outfit_id))
+ except ValueError as exc:
+ raise BizException(str(exc), code=BizCode.BAD_REQUEST) from exc
+ return Response.success(view, message="已丢弃待审模型")
diff --git a/backend/packages/app/src/windup_app/web/api/workflow_run.py b/backend/packages/app/src/windup_app/web/api/workflow_run.py
index 23a2da71..3802b7fc 100644
--- a/backend/packages/app/src/windup_app/web/api/workflow_run.py
+++ b/backend/packages/app/src/windup_app/web/api/workflow_run.py
@@ -52,6 +52,7 @@ class WorkflowRunCreate(BaseModel):
class WorkflowRunUpdate(BaseModel):
"""全量更新执行记录。"""
+ version: int = Field(ge=1, description="客户端读到的当前版本号")
nodes: list | None = Field(
default=None,
description="节点树(前端自定义结构,后端不校验)",
@@ -171,7 +172,13 @@ def update_run(
code=BizCode.BAD_REQUEST,
) from None
- run = service.update_run(session, run_id, nodes=body.nodes, status=status)
+ run = service.update_run(
+ session,
+ run_id,
+ expected_version=body.version,
+ nodes=body.nodes,
+ status=status,
+ )
return Response.success(WorkflowRunOut.model_validate(run), message="更新成功")
diff --git a/backend/packages/app/src/windup_app/web/handler/exception_handlers.py b/backend/packages/app/src/windup_app/web/handler/exception_handlers.py
index b143514a..8feef104 100644
--- a/backend/packages/app/src/windup_app/web/handler/exception_handlers.py
+++ b/backend/packages/app/src/windup_app/web/handler/exception_handlers.py
@@ -36,18 +36,37 @@ def handle_request_validation_error(
) -> JSONResponse:
"""请求参数校验失败(FastAPI 默认 422)-> ``code=BizCode.BAD_REQUEST``,data 带校验明细。
+ ``message`` 取第一条校验错误的原文而不是一句笼统的"请求参数校验失败":前端展示的是
+ ``message``,把原因只放进 ``data`` 等于用户永远看不到——实测用户看到的是读不懂的
+ "请求参数校验失败",而真正的原因("custom 动作必须提供 custom_prompt")就在 data 里躺着。
+
``exc.errors()`` 的 ``ctx`` 可能含不可 JSON 序列化的对象(如 ``ValueError``),
过一道 ``jsonable_encoder`` 保险。
"""
+ detail = jsonable_encoder(exc.errors())
return _jsonify(
Response.fail(
- "请求参数校验失败",
+ _first_validation_message(detail),
code=BizCode.BAD_REQUEST,
- data=jsonable_encoder(exc.errors()),
+ data=detail,
)
)
+# pydantic 把自定义 ValueError 的原文前缀成 "Value error, xxx",对用户是噪声。
+_VALUE_ERROR_PREFIX = "Value error, "
+
+
+def _first_validation_message(errors: object) -> str:
+ """取第一条校验错误的可读原文;取不到就退回笼统文案(总比抛在处理器里强)。"""
+ if isinstance(errors, list):
+ for e in errors:
+ msg = (e or {}).get("msg") if isinstance(e, dict) else None
+ if isinstance(msg, str) and msg:
+ return msg.removeprefix(_VALUE_ERROR_PREFIX)
+ return "请求参数校验失败"
+
+
def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
"""FastAPI ``HTTPException`` -> 业务码取 ``status_code``,message 取 ``detail``。"""
return _jsonify(Response.fail(str(exc.detail), code=exc.status_code))
diff --git a/backend/packages/app/src/windup_app/web/middleware/.gitkeep b/backend/packages/app/src/windup_app/web/middleware/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/app/src/windup_app/web/middleware/ratelimit.py b/backend/packages/app/src/windup_app/web/middleware/ratelimit.py
deleted file mode 100644
index 11a05733..00000000
--- a/backend/packages/app/src/windup_app/web/middleware/ratelimit.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""接口限流中间件。
-
-基于 Redis 的滑动窗口计数器,在鉴权中间件之前执行。
-Redis 不可用时优雅降级(跳过限流)。
-"""
-
-import logging
-
-from fastapi.responses import JSONResponse
-from starlette.middleware.base import BaseHTTPMiddleware
-from starlette.requests import Request
-from starlette.responses import Response
-
-from windup_common.enums.biz_code import BizCode
-from windup_common.result import Response as Resp
-
-logger = logging.getLogger("windup.ratelimit")
-
-# -- 限流配置 ------------------------------------------------------------
-
-# 全局 API 限流:单 IP 60 次/分钟
-GLOBAL_RATE = 60
-GLOBAL_WINDOW = 60
-
-# 敏感接口限流:单 IP 10 次/分钟
-SENSITIVE_RATE = 10
-SENSITIVE_WINDOW = 60
-
-# 用户级限流:120 次/分钟
-USER_RATE = 120
-USER_WINDOW = 60
-
-# 敏感接口路径
-SENSITIVE_PATHS: set[str] = {
- "/auth/register",
- "/auth/login",
- "/auth/send-code",
- "/auth/login-by-code",
- "/auth/reset-password",
-}
-
-# -- Redis key 模板 ------------------------------------------------------
-
-RATELIMIT_API_KEY = "ratelimit:api:{ip}"
-RATELIMIT_SENSITIVE_KEY = "ratelimit:sensitive:{ip}"
-RATELIMIT_USER_KEY = "ratelimit:api:{user_id}"
-
-
-# 可信代理列表:只有这些来源的请求才信任 X-Forwarded-For
-TRUSTED_PROXIES: set[str] = {"127.0.0.1", "::1", "172.16.0.0/12"}
-
-
-def _is_trusted_proxy(host: str | None) -> bool:
- """判断请求来源是否在可信代理列表中。"""
- if not host:
- return False
- if host in TRUSTED_PROXIES:
- return True
- # Docker 网段 172.16.0.0/12
- try:
- parts = host.split(".")
- if len(parts) == 4 and parts[0] == "172" and 16 <= int(parts[1]) <= 31:
- return True
- except (ValueError, IndexError):
- pass
- return False
-
-
-def _get_client_ip(request: Request) -> str:
- """获取客户端 IP,仅在可信代理后才信任 X-Forwarded-For。"""
- client_host = request.client.host if request.client else None
- if _is_trusted_proxy(client_host):
- forwarded = request.headers.get("x-forwarded-for")
- if forwarded:
- return forwarded.split(",")[0].strip()
- return client_host or "unknown"
-
-
-def _check_rate(redis_client, key: str, limit: int, window: int) -> bool:
- """检查是否超出限流,返回 True 表示允许通过。"""
- try:
- current = redis_client.incr(key)
- if current == 1:
- redis_client.expire(key, window)
- return current <= limit
- except Exception:
- # Redis 不可用时跳过限流
- logger.warning("[WINDUP] Redis 不可用,跳过限流检查 | key=%s", key)
- return True
-
-
-class RateLimitMiddleware(BaseHTTPMiddleware):
- """接口限流中间件。"""
-
- def __init__(self, app) -> None:
- super().__init__(app)
- self._redis = None
- self._redis_available = True
-
- @property
- def redis(self):
- if self._redis is None:
- try:
- from windup_framework.db.redis import get_redis
- self._redis = get_redis()
- # 测试连接
- self._redis.ping()
- except Exception:
- self._redis_available = False
- logger.warning("[WINDUP] Redis 连接失败,限流中间件将跳过限流检查")
- return None
- return self._redis
-
- async def dispatch(self, request: Request, call_next) -> Response:
- # Redis 不可用时直接放行
- if not self._redis_available or self.redis is None:
- return await call_next(request)
-
- client_ip = _get_client_ip(request)
-
- # 全局限流
- if not _check_rate(self.redis, RATELIMIT_API_KEY.format(ip=client_ip), GLOBAL_RATE, GLOBAL_WINDOW):
- logger.warning("[WINDUP] 全局限流触发 | ip=%s path=%s", client_ip, request.url.path)
- return JSONResponse(
- status_code=200,
- content=Resp.fail("请求过于频繁", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"),
- )
-
- # 敏感接口额外限流
- if request.url.path in SENSITIVE_PATHS:
- if not _check_rate(self.redis, RATELIMIT_SENSITIVE_KEY.format(ip=client_ip), SENSITIVE_RATE, SENSITIVE_WINDOW):
- logger.warning("[WINDUP] 敏感接口限流触发 | ip=%s path=%s", client_ip, request.url.path)
- return JSONResponse(
- status_code=200,
- content=Resp.fail("请求过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"),
- )
-
- # 用户级限流(已登录用户)
- user_id = getattr(getattr(request.state, "current_user", None), "id", None)
- if user_id is not None:
- if not _check_rate(self.redis, RATELIMIT_USER_KEY.format(user_id=user_id), USER_RATE, USER_WINDOW):
- logger.warning("[WINDUP] 用户限流触发 | user_id=%s", user_id)
- return JSONResponse(
- status_code=200,
- content=Resp.fail("请求过于频繁", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"),
- )
-
- return await call_next(request)
diff --git a/backend/packages/app/src/windup_app/web/schemas/.gitkeep b/backend/packages/app/src/windup_app/web/schemas/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/app/src/windup_app/worker/.gitkeep b/backend/packages/app/src/windup_app/worker/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/common/src/windup_common/constants/.gitkeep b/backend/packages/common/src/windup_common/constants/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/common/src/windup_common/enums/.gitkeep b/backend/packages/common/src/windup_common/enums/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/common/src/windup_common/enums/__init__.py b/backend/packages/common/src/windup_common/enums/__init__.py
index 32fa60d1..7e5a2aff 100644
--- a/backend/packages/common/src/windup_common/enums/__init__.py
+++ b/backend/packages/common/src/windup_common/enums/__init__.py
@@ -1,6 +1,7 @@
"""共享枚举。"""
from windup_common.enums.biz_code import BizCode
+from windup_common.enums.character import CharacterStatus
from windup_common.enums.model import ModelErrorType
-__all__ = ["BizCode", "ModelErrorType"]
+__all__ = ["BizCode", "CharacterStatus", "ModelErrorType"]
diff --git a/backend/packages/common/src/windup_common/enums/biz_code.py b/backend/packages/common/src/windup_common/enums/biz_code.py
index 382a8596..7cbdfdad 100644
--- a/backend/packages/common/src/windup_common/enums/biz_code.py
+++ b/backend/packages/common/src/windup_common/enums/biz_code.py
@@ -22,6 +22,7 @@ class BizCode(int, Enum):
BAD_REQUEST = 400 # 请求参数校验失败
UNAUTHORIZED = 401 # 未登录 / token 无效
NOT_FOUND = 404 # 资源不存在
+ CONFLICT = 409 # 乐观锁 / 资源版本冲突
TOO_MANY_REQUESTS = 429 # 请求过于频繁
INTERNAL_ERROR = 500 # 服务器内部错误 / 兜底
MODEL_UNAVAILABLE = 503 # 模型服务不可用
diff --git a/backend/packages/common/src/windup_common/enums/character.py b/backend/packages/common/src/windup_common/enums/character.py
new file mode 100644
index 00000000..d81a4484
--- /dev/null
+++ b/backend/packages/common/src/windup_common/enums/character.py
@@ -0,0 +1,28 @@
+"""角色资产状态枚举。"""
+
+from enum import IntEnum
+
+
+class CharacterStatus(IntEnum):
+ """角色发布状态。
+
+ - ``DRAFT (0)``: 草稿——尚无真实动作帧。
+ - ``PUBLISHED (1)``: 已发布——至少存在一条包含真实帧的动作。
+ """
+
+ DRAFT = 0
+ PUBLISHED = 1
+
+ @classmethod
+ def from_character_data(cls, character_data: dict) -> "CharacterStatus":
+ """根据 character_data 推断发布状态。
+
+ 判定规则:至少存在一条包含真实帧(frame_count > 0 且 frames 非空)的动作
+ 即为已发布;否则为草稿。
+ """
+ for outfit in character_data.get("outfits", []):
+ for action in outfit.get("actions", []):
+ frames = action.get("frames", [])
+ if frames and action.get("frame_count", 0) > 0:
+ return cls.PUBLISHED
+ return cls.DRAFT
diff --git a/backend/packages/common/src/windup_common/enums/quota.py b/backend/packages/common/src/windup_common/enums/quota.py
new file mode 100644
index 00000000..c84fde1a
--- /dev/null
+++ b/backend/packages/common/src/windup_common/enums/quota.py
@@ -0,0 +1,37 @@
+"""积分模块共享枚举。
+
+CreditReason — 积分变动原因
+BillingMode — 预付费 / 后付费
+
+定价参数由 ``windup_framework.config.quota.settings`` 提供,此处不硬编码。
+"""
+
+from enum import IntEnum
+
+
+class CreditReason(IntEnum):
+ """积分变动原因。
+
+ 对应 ``windup_credit_transaction.reason`` 列。
+ """
+
+ REGISTER_GIFT = 1 # 注册赠送
+ INVITE_REWARD = 2 # 邀请奖励
+ GENERATE_IMAGE = 3 # 生成角色参考图
+ GENERATE_ACTION = 4 # 生成角色动作
+ ADMIN_ADJUST = 5 # 管理员手动调整
+ REFUND = 6 # 退款 / 回退 / 解冻退回
+ FROZEN = 7 # 预付费冻结(占用余额)
+ CAPTURED = 8 # 预付费实际扣减(冻结转消耗)
+ # AGENT_TOKEN = 9 # Agent token 消耗(后付费)先不实现后付费场景
+
+
+class BillingMode(IntEnum):
+ """计费模式。
+
+ 对应 ``windup_credit_transaction.billing_mode`` 列。
+ """
+
+ PREPAID = 0 # 预付费(生成任务:冻结→扣减/解冻)
+ # POSTPAID = 1 # 后付费(Agent token:用完再扣)
+
diff --git a/backend/packages/common/src/windup_common/exceptions/.gitkeep b/backend/packages/common/src/windup_common/exceptions/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/common/src/windup_common/models/.gitkeep b/backend/packages/common/src/windup_common/models/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py
new file mode 100644
index 00000000..688979b1
--- /dev/null
+++ b/backend/packages/common/src/windup_common/models/__init__.py
@@ -0,0 +1,23 @@
+from windup_common.models.character import (
+ DEFAULT_N_FRAMES,
+ ActionSpec,
+ ActionType,
+ AttackArchetype,
+ CharacterCard,
+ CharacterView,
+ Facing,
+ GenRoute,
+ Stylize,
+)
+
+__all__ = [
+ "ActionType",
+ "AttackArchetype",
+ "GenRoute",
+ "Facing",
+ "CharacterView",
+ "Stylize",
+ "DEFAULT_N_FRAMES",
+ "CharacterCard",
+ "ActionSpec",
+]
diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py
new file mode 100644
index 00000000..92f4430d
--- /dev/null
+++ b/backend/packages/common/src/windup_common/models/character.py
@@ -0,0 +1,286 @@
+"""共享 DTO —— 跨层契约(common,无内部依赖)。
+
+产品核心实体的数据模型:角色卡(一致性主键)、动作规格、生成路线枚举。
+仅定义结构,不含行为。ai_engine / app 均依赖此。
+
+**为什么受限取值一律用枚举而不是裸 str(2026-08-08 收紧)**:
+``facing`` 承载的是一条实测挣得的硬约束——"提示词朝向必须与母版朝向一致"
+(见 ai_engine.master_prep:给正面母版喂侧走词,模型会靠转身调和图文矛盾)。
+它此前是裸 str、合法值只写在行尾注释里:写成 "Side" / "sidee" 不报错、不告警,
+调用链一路放行,几分钟和一次真金白银的视频调用之后才在画面上看出角色转了身。
+枚举把这类错误从"生成完靠肉眼发现"提前到"构造 ActionSpec 时 ValidationError",
+成本从一次付费生成降到零。``loop`` / ``stylize`` / ``view`` 同理。
+"""
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+# 未知字段一律报错(pydantic 默认是 extra="ignore",静默丢弃)。理由与本文件用枚举取代裸
+# str 完全同源:字段名也是靠字符串传递的约束。`ActionSpec(action=..., n_frame=16)`(少个 s)
+# 在 ignore 下不报错、不生效,调用方以为要了 16 帧、实际拿到默认 8 帧;`CharacterCard(
+# palette=...)` 这类已删字段同理会被静默吞掉。forbid 让这些当场变成 ValidationError。
+_STRICT = ConfigDict(extra="forbid")
+
+
+def _without(data: dict[str, Any], key: str) -> dict[str, Any]:
+ """去掉某键的浅拷贝(不改调用方传进来的 dict —— before 校验器拿到的是原对象)。"""
+ return {k: v for k, v in data.items() if k != key}
+
+
+class ActionType(str, Enum):
+ """动作类型 —— 决定走哪条生成 strategy(见 ai_engine.strategy.ROUTE_MATRIX)。
+
+ **本枚举是"引擎能生成的动作",不是"API 能接收的动作",两者刻意分离。** 入口侧的
+ ``windup_app.server.orchestrator.model.ActionType`` 另有 ``custom``,且少 run /
+ jump / hit;跨越两者靠编排层的显式适配函数 ``_to_engine_action``,它对引擎没有路线
+ 的类型抛带原因的错误,而不是让请求走到一半失败。
+
+ ``custom`` 是先做出履约能力(提示词构建 + 循环性显式声明 + 路线分流)才加的成员,
+ 不是提前留位——加了没有分流的成员等于接收一个无法履约的请求。
+
+ 入口枚举(``orchestrator.model.ActionType``)仍与本枚举分离:它少 run / hit,
+ 跨越靠 ``_to_engine_action``。
+ """
+
+ IDLE = "idle"
+ WALK = "walk"
+ RUN = "run"
+ JUMP = "jump" # 一次性动作,且要按状态切段(见 postprocess.split_jump_phases)
+ ATTACK = "attack" # slash / thrust / dash 归此
+ HIT = "hit"
+ # 用户自述动作。与上面几个的**结构性差异**:上面每个都自带一套写死的产线设定
+ # (提示词模板、循环性、所需母版姿态),而 custom 的这些只能由调用方随请求给出。
+ # 故 ``ActionSpec`` 对它多要两个字段(``custom_action`` / ``cyclic``),缺一个就报错 ——
+ # 见该类的校验器。Refs 1024XEngineer/Windup#239。
+ CUSTOM = "custom"
+
+
+class AttackArchetype(str, Enum):
+ """攻击的运动拓扑 —— 决定 ``prompts/attack.md`` 取哪一节(取值即节名前缀)。
+
+ 按"身体怎么发力"分而不按装备形状分:提示词里的形状先验(宽面、弧线)等于断言角色
+ 手握一件有宽面的长条物,喂空手 / 法杖 / 四足角色时模型会凭空补出那件东西来调和矛盾。
+ """
+
+ SWEEP = "sweep" # 长条持物:横挥 / 下劈
+ THRUST = "thrust" # 短持物或空手:直出 / 戳刺
+ PROJECT = "project" # 远程:身体前压、送到位、终态保持
+ LUNGE = "lunge" # 非双足:整体前扑,头部 / 前肢领先
+
+
+class GenRoute(str, Enum):
+ """生成路线 —— 实测挣得的分流依据(见 ai_engine.strategy 层 docstring)。
+
+ **只列有实现的路线。** 没有实现的枚举值等于死代码:它会让调用方以为该能力存在,
+ 而分流到它只能得到运行时错误。未来路线(如三渲二渲染出帧)的契约需求记在 Issue 里
+ (见 #81 #122),随实现一起加成员 —— 枚举加成员是纯加法,不构成破坏性变更。
+ """
+
+ VIDEO_I2V = "video_i2v" # 步态位移动作:图生视频(连贯交替腿)
+ PER_FRAME = "per_frame" # 离散姿势:逐帧图生图(单帧可编辑)
+ # 三渲二:母版 → 图生 3D → 自动绑骨 → 套预设动作 → 渲 2D 序列帧。与上面两条有个
+ # **结构性差异**:前两条由动作的物理性质唯一决定,这一条还取决于"该造型有没有 3D
+ # 资产"。所以它**不进 ROUTE_MATRIX** —— 由 server 读 DB 后直接调
+ # ``CharacterGeneratorPort.generate_rendered``(#122)。
+ RENDER_3D = "render_3d"
+
+
+class Facing(str, Enum):
+ """提示词朝向 —— **必须与母版朝向一致**(硬约束,见 ai_engine.master_prep)。
+
+ - ``SIDE``:横版侧视,角色朝画面右侧行进(母版也须朝侧向)。
+ - ``FRONT``:身体正对观者(俯视与 2.5D 都归此)。
+
+ 与 :class:`CharacterView` 的对应关系:SIDE→SIDE;TOP_DOWN / ISOMETRIC→FRONT。
+ 两者不合并成一个枚举:view 是项目级美术视角(对应 ``Project.character_perspective``,
+ 决定母版怎么画),facing 是提示词模板的二选一(只区分"看得到侧面"和"正对镜头")。
+ """
+
+ SIDE = "side"
+ FRONT = "front"
+
+
+class CharacterView(str, Enum):
+ """角色美术视角 —— 与 ``Project.character_perspective``(1/2/3)一一对应。
+
+ 映射固定为 1→side、2→top-down、3→isometric。字符串取值必须逐字一致,
+ 免得调用方再造一套别名(如 topdown / top_down / top-down 三写)。
+ """
+
+ SIDE = "side" # perspective=1 横版
+ TOP_DOWN = "top-down" # perspective=2 俯视
+ ISOMETRIC = "isometric" # perspective=3 2.5D
+
+
+class Stylize(str, Enum):
+ """风格化模式。
+
+ ``PIXEL``=像素化(原生像素角色 i2v 后复原像素感);``NONE``=保留 i2v 的插画质感。
+ 不该焊死——插画风角色像素化会出不协调色块(有损近似);默认由角色画风决定。
+ """
+
+ PIXEL = "pixel"
+ NONE = "none"
+
+
+# 视频路线未指定帧数时的默认出帧数。原先以 `action.n_frames or 8` 的形式藏在
+# strategy.concrete 里,是"契约的缺省值写在实现里"——换个 strategy 就换个默认值。
+DEFAULT_N_FRAMES = 8
+
+
+class CharacterCard(BaseModel):
+ """角色卡 —— 一致性主键 + 资产库基础(产品核心实体)。
+
+ 注意:视频路线**不读本模型的任何字段**,角色身份由母版图像承载。
+ 详见 ``windup_ai_engine.ports.CharacterGeneratorPort`` 的 docstring。
+ """
+
+ model_config = _STRICT
+
+ name: str
+ desc: str # 身份描述(喂模型锁一致性)
+ view: CharacterView = CharacterView.SIDE
+ master_ref: str = "" # 定妆母版的存储 ref(对象存储,非本地路径)
+ version: str = "v1"
+
+ # 注:曾有 `palette: str = ""`。2026-08-08 删除,理由是它会变成"看起来生效、实则被
+ # 忽略"的第二真相源:真正锁色的色板由 postprocess.master_pixel_spec 从母版像素里量出来
+ # (ndarray,喂给 _snap_to_palette),而这个字段零消费方、无格式约定。调用方填了
+ # "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,不报错也不生效——正是本项目最忌讳的
+ # "看起来成功的错结果"。将来若要支持用户指定色板,连同消费它的代码一起加回,并用结构化
+ # 类型(如 list[str] 且校验 hex)而不是自由 str。
+
+
+class ActionSpec(BaseModel):
+ """动作规格 —— 帧数 / 逐帧姿势 / 风格化 / 朝向。
+
+ **播放时序的唯一真相源是出参的 ``durations``(逐帧 ms),不是入参的帧率。**
+ 这里曾有 ``fps`` 与 ``loop`` 两个字段,都已删除,理由与 :class:`GenRoute`
+ docstring 里那条一致——没有实现的取值等于死代码,它让调用方以为该能力存在:
+
+ - ``fps``:零写入方(编排层构造 ActionSpec 时从不传),而 ``postprocess.
+ frame_durations`` 按动作查表、**根本不看它**。留着的后果是 ``fps=20`` 宣称
+ 50ms/帧、walk 实际返回 125ms/帧,两个字段描述同一段素材的不同播放速度。
+ - ``loop``:零消费方。闭环行为写死在 ``slicing.pick_cycle`` 里——循环类动作
+ 一律抽单周期闭环,传 ``pingpong`` / ``none`` 不改变任何产出。调用方可以为一段
+ 往返动画付费、拿到一段线性循环,正是本项目最忌讳的"静默成功"。
+
+ 真要支持 pingpong,连同 ``pick_cycle`` 的分支、出参的时序契约一起加回。
+ """
+
+ model_config = _STRICT
+
+ action: ActionType
+ # 描述与循环性拆成两个字段并加双向校验,而不是一个无校验器的可选 motion_prompt:
+ # 后者设在 walk / jump / attack 上会顶掉那几套模板,也不承载循环性,两种都不会红。
+
+ # 出帧数。**显式字段,不再由 len(poses) 推导**:视频路线根本不读 poses(见
+ # strategy.concrete.VideoFrameStrategy),推导意味着"想要 16 帧就得先编 16 条用不上的
+ # 姿势描述",而那 16 条描述读者会以为真的进了提示词。
+ n_frames: int = Field(default=DEFAULT_N_FRAMES, ge=1)
+
+ # 逐帧路线专用:每帧一条姿势描述,只有 PER_FRAME 会真的读它。
+ poses: list[str] = Field(default_factory=list)
+
+ stylize: Stylize = Stylize.PIXEL
+ # 两个下界抄的是实现里已经存在的真实取值域,把"实现悄悄纠正入参"提前成入参报错:
+ # pixel_h → postprocess.to_pixel_art 对 <1 直接 raise,契约没理由比实现更宽松;
+ # palette_size → 同处 `quantize(colors=max(2, palette_size))` 会把 1 静默抬成 2,
+ # 于是"我要 1 色"拿到 2 色且无任何提示 —— 正是本项目最忌讳的静默纠正。
+ pixel_h: int = Field(default=100, ge=1) # 像素化目标高(角色像素行数)
+ palette_size: int = Field(default=32, ge=2) # 色板色数(1 色的像素画不存在)
+ # 生成提示词的朝向,**必须与母版朝向一致**(对应 Project.perspective)。
+ facing: Facing = Facing.SIDE
+
+ # ── 仅 action=CUSTOM 用的两个字段(#239)────────────────────────────────
+ #
+ # 其余动作各自自带一套写死的产线设定(提示词模板、循环性、所需母版姿态),
+ # custom 没有 —— 只能由调用方随请求给出。缺任何一个都在构造时炸,见下面的校验器。
+
+ # 用户自述的动作内容。**只写"做什么动作"**,不复述角色外观:身份由母版承载,
+ # 身份描述再写一遍会和母版打架(见 ports.CharacterGeneratorPort)。
+ custom_action: str | None = None
+
+ # 必须显式给,不按描述关键词猜:猜错会把一次性动作强行首尾闭环,末帧接回首帧抽搐,
+ # 而帧数、时长、成色全正常。名字不叫 loop 是因为它有真实消费方——决定 slicing 走
+ # pick_cycle 还是 pick_oneshot、出参要不要量 loop_seam。
+ cyclic: bool | None = None
+
+ # 攻击走哪一支运动拓扑。``None`` = 不指定,由 ``build_attack_prompt`` 的默认值决定 ——
+ # 这里不给默认值,否则同一个缺省被两处各写一份,改一处另一处静默不动。
+ archetype: AttackArchetype | None = None
+
+ @model_validator(mode="after")
+ def _archetype_belongs_to_attack_only(self) -> ActionSpec:
+ """非攻击动作带 archetype 要炸:它只被攻击提示词消费,传了不会生效。"""
+ if self.action is not ActionType.ATTACK and self.archetype is not None:
+ raise ValueError(
+ f"action={self.action.value} 不该带 archetype;它只决定攻击提示词取哪一支,传了不会生效"
+ )
+ return self
+
+ @model_validator(mode="after")
+ def _custom_needs_its_own_settings(self) -> ActionSpec:
+ """两个方向都卡:缺了只能猜、而猜错是静默的;多给了调用方以为能覆盖 walk 的
+ 循环性,实际被写死的表覆盖。
+ """
+ if self.action is ActionType.CUSTOM:
+ if not (self.custom_action or "").strip():
+ raise ValueError("action=custom 必须给 custom_action(动作描述),否则无从构建提示词")
+ if self.cyclic is None:
+ raise ValueError(
+ "action=custom 必须显式给 cyclic(是否循环播放)。不猜 —— "
+ "猜错会把一次性动作强行首尾闭环,而帧数/时长/成色全部正常、没有任何一道会红"
+ )
+ else:
+ if self.custom_action is not None:
+ raise ValueError(f"action={self.action.value} 不该带 custom_action;它的提示词由模板给")
+ if self.cyclic is not None:
+ raise ValueError(
+ f"action={self.action.value} 不该带 cyclic;循环性由 CYCLIC_ACTIONS 写死,"
+ "传了不会生效"
+ )
+ return self
+ # 这里**没有** ``route`` 字段:路线选择整个在 server —— 走不走三渲二取决于"这个造型
+ # 有没有 3D 资产",那份数据在 DB 里,server 读完直接调 ``generate_rendered``。
+ # 加一个零消费方的字段等于留一个"填了看起来会生效、实际没人读"的入参。
+
+ @model_validator(mode="before")
+ @classmethod
+ def _reconcile_n_frames_with_poses(cls, data: Any) -> Any:
+ """兼容旧调用方(只传 poses),并让 n_frames 与 poses 打架时**炸掉而不是猜**。
+
+ - 只给 poses:帧数仍取 len(poses),旧调用方零改动。
+ - 两个都给且不等:抛错。此时规格自相矛盾,引擎无法知道调用方要 16 帧还是 12 帧
+ (common 层看不到 ROUTE_MATRIX,判不出走哪条路线),猜一个的代价是静默出错帧数。
+ 走视频路线的调用方本就不该传 poses,删掉即可。
+ - 显式 ``None`` 一律等同"没传"(两条分支一致):调用方写
+ ``n_frames=form.get("n_frames")`` 时 None 表示"未指定",该走缺省,不该炸。
+ """
+ if not isinstance(data, dict): # model_validate(实例) 等非 dict 入参原样放行
+ return data
+ n = data.get("n_frames")
+ poses = data.get("poses")
+ if n is None:
+ # 有 poses 就回退到 len(poses),没有则删键让字段缺省值(DEFAULT_N_FRAMES)生效。
+ # 不能原样留 None:`n_frames: int` 会报 "Input should be a valid integer",
+ # 于是"显式 None"在有/无 poses 两种情况下行为不一致(一个回退、一个报错)。
+ return {**data, "n_frames": len(poses)} if poses else _without(data, "n_frames")
+ if not poses:
+ return data
+ # 先按 int 归一再比:pydantic 之后会把 JSON 里的 "2" 收成 2,而这里若直接 `n != len`
+ # 比较,``{"n_frames": "2", "poses": ["a","b"]}`` 会得到自相矛盾的报错
+ # 「n_frames=2 与 len(poses)=2 不一致」——把一次合法请求判成打架(2026-08-08 实测)。
+ try:
+ n_int = int(n)
+ except (TypeError, ValueError):
+ return data # 类型本就不对 → 交给字段校验报正经的 int 错
+ if n_int != len(poses):
+ raise ValueError(
+ f"n_frames={n_int} 与 len(poses)={len(poses)} 不一致;"
+ "逐帧路线要求两者相等,视频路线不该传 poses。"
+ )
+ return data
diff --git a/backend/packages/common/src/windup_common/result/.gitkeep b/backend/packages/common/src/windup_common/result/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/common/src/windup_common/utils/.gitkeep b/backend/packages/common/src/windup_common/utils/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/auth/.gitkeep b/backend/packages/framework/src/windup_framework/auth/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/config/.gitkeep b/backend/packages/framework/src/windup_framework/config/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/config/__init__.py b/backend/packages/framework/src/windup_framework/config/__init__.py
index 2f4cd973..59fc24bb 100644
--- a/backend/packages/framework/src/windup_framework/config/__init__.py
+++ b/backend/packages/framework/src/windup_framework/config/__init__.py
@@ -1,14 +1,22 @@
-"""framework 配置。"""
+"""framework 配置。
+
+所有安全敏感字段(JWT_SECRET、POSTGRES_PASSWORD)均为必填项,
+Pydantic Settings 在模块导入(实例化)时即完成校验,缺失或不合规直接抛出
+``ValidationError`` — 进程在启动前失败(fail-fast)。
+"""
from windup_framework.config.database import DatabaseSettings, settings
+from windup_framework.config.jwt import JWTSettings, settings as jwt_settings
from windup_framework.config.provider import AIProviderSettings, settings as provider_settings
from windup_framework.config.storage import StorageSettings, settings as storage_settings
__all__ = [
"AIProviderSettings",
"DatabaseSettings",
+ "JWTSettings",
"StorageSettings",
"provider_settings",
"settings",
+ "jwt_settings",
"storage_settings",
]
diff --git a/backend/packages/framework/src/windup_framework/config/database.py b/backend/packages/framework/src/windup_framework/config/database.py
index 6cb3d483..456bc7c2 100644
--- a/backend/packages/framework/src/windup_framework/config/database.py
+++ b/backend/packages/framework/src/windup_framework/config/database.py
@@ -1,10 +1,12 @@
"""Postgres 数据库连接配置。
从环境变量(或 ``.env``)读取,字段前缀 ``POSTGRES_``。
-本地开发默认值对应 Docker 容器 root/admin123@localhost:4000。
+
+``password`` 为必填项,无代码默认值 — 缺失时 Pydantic 在实例化阶段直接抛出
+``ValidationError``。本地开发请在 ``.env`` 或 ``.env.dev`` 中显式配置。
"""
-from pydantic import Field
+from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
@@ -23,12 +25,22 @@ class DatabaseSettings(BaseSettings):
host: str = "localhost"
port: int = 4000
user: str = "root"
- password: str = "admin123"
+ password: str = Field(...) # 必填,无默认值
db: str = Field(default="windup")
pool_size: int = 5
max_overflow: int = 10
pool_pre_ping: bool = True
+ @field_validator("password")
+ @classmethod
+ def _check_password_not_trivial(cls, v: str) -> str:
+ if len(v) < 8:
+ raise ValueError(
+ "POSTGRES_PASSWORD 长度不足 8 字符。"
+ "请使用强密码以保障数据库安全。"
+ )
+ return v
+
@property
def url(self) -> str:
"""SQLAlchemy 连接串(psycopg3 驱动)。
diff --git a/backend/packages/framework/src/windup_framework/config/jwt.py b/backend/packages/framework/src/windup_framework/config/jwt.py
index 037cd90f..0e2eeb9f 100644
--- a/backend/packages/framework/src/windup_framework/config/jwt.py
+++ b/backend/packages/framework/src/windup_framework/config/jwt.py
@@ -1,8 +1,12 @@
"""JWT 配置。
从环境变量(或 ``.env``)读取,字段前缀 ``JWT_``。
+
+``secret`` 为必填项,无代码默认值 — 缺失时 Pydantic 在实例化阶段直接抛出
+``ValidationError``,进程在启动前即失败(fail-fast)。
"""
+from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,7 +20,19 @@ class JWTSettings(BaseSettings):
extra="ignore",
)
- secret: str = "change-me-in-production"
+ secret: SecretStr
+
+ @field_validator("secret")
+ @classmethod
+ def _check_secret_strength(cls, v: SecretStr) -> SecretStr:
+ raw = v.get_secret_value()
+ if len(raw) < 32:
+ raise ValueError(
+ "JWT_SECRET 长度不足 32 字符,不符合安全要求。"
+ "请使用至少 32 字符的随机密钥:\n"
+ " JWT_SECRET=$(openssl rand -hex 32)"
+ )
+ return v
settings = JWTSettings()
diff --git a/backend/packages/framework/src/windup_framework/config/provider.py b/backend/packages/framework/src/windup_framework/config/provider.py
index 57182ce5..72758f95 100644
--- a/backend/packages/framework/src/windup_framework/config/provider.py
+++ b/backend/packages/framework/src/windup_framework/config/provider.py
@@ -16,11 +16,25 @@ class AIProviderSettings(BaseSettings):
provider: str = "openai-compatible"
base_url: str = "https://api.openai.com/v1"
api_key: str = ""
- model: str = ""
+ model: str = "" # 通用兜底(chat 类调用),下面三个各自专用
timeout: float = 120.0
max_retries: int = 2
chat_completions_path: str = "/chat/completions"
+ # ── 各能力用哪个模型 ──────────────────────────────────────────────────
+ # 分成字段而不是共用上面那个 ``model``:各能力同时在用不同模型,共用一个
+ # 字段意味着换其中一个就把另外两个也换了。默认值即当前实测在用的型号,
+ # 部署侧可用 AI_CHAT_MODEL / AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。
+ #
+ # **只有型号可配,请求形状不可配**:哪个模型吃 image_list、哪个吃
+ # input_reference、FAL 队列路径长什么样,都是该模型的 API 事实而非运行参数,
+ # 写在 providers.sufy 的映射表里。放进配置会把"填错了会怎样"从部署期推到
+ # 运行期 —— 字段塞错不会立刻报错,任务照常 queued,直到生成阶段才 failed,
+ # 而费用可能已经产生(2026-07-29 实测)。
+ chat_model: str = "gpt-4o-mini"
+ video_model: str = "kling-v2-5-turbo"
+ image_model: str = "gemini-2.5-flash-image"
+
@property
def normalized_base_url(self) -> str:
return self.base_url.rstrip("/")
diff --git a/backend/packages/framework/src/windup_framework/config/quota.py b/backend/packages/framework/src/windup_framework/config/quota.py
new file mode 100644
index 00000000..af1ca72c
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/config/quota.py
@@ -0,0 +1,33 @@
+"""积分定价配置。
+
+所有定价参数通过环境变量 / .env 文件注入,支持后续配置管理平台。
+"""
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class QuotaSettings(BaseSettings):
+ """积分定价配置。
+
+ 环境变量前缀 ``QUOTA_``,例如 ``QUOTA_REGISTER_GIFT_AMOUNT=300``。
+ """
+
+ model_config = SettingsConfigDict(
+ env_prefix="QUOTA_",
+ env_file=("../.env", ".env"),
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+ # -- 注册 / 邀请 -------------------------------------------------------
+ register_gift_amount: int = 300 # 注册赠送积分
+ invite_reward_amount: int = 200 # 邀请奖励(双方各得)
+ invite_reward_daily_limit: int = 3 # 邀请人每日可获奖励的邀请人数(3×200=600)
+ invite_code_ttl_days: int = 30 # 邀请码有效期(天)
+
+ # -- 生成任务 -----------------------------------------------------------
+ generate_image_cost: int = 10 # 生成角色参考图
+ generate_action_cost: int = 50 # 生成角色动作
+
+
+settings = QuotaSettings()
diff --git a/backend/packages/framework/src/windup_framework/config/storage.py b/backend/packages/framework/src/windup_framework/config/storage.py
index 06eff8c0..b3550607 100644
--- a/backend/packages/framework/src/windup_framework/config/storage.py
+++ b/backend/packages/framework/src/windup_framework/config/storage.py
@@ -4,6 +4,8 @@
本地开发需在 ``.env`` 填入 AccessKey / SecretKey / Bucket / 绑定域名。
"""
+from urllib.parse import urlsplit
+
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -32,8 +34,22 @@ class StorageSettings(BaseSettings):
@property
def download_base(self) -> str:
- """下载 URL 基础域名,去掉末尾 ``/``,客户端拼接 key 即可。"""
- return self.bucket_domain.rstrip("/")
+ """返回浏览器可访问的下载域名。"""
+ domain = self.bucket_domain.rstrip("/")
+ if domain.startswith("http://"):
+ raise ValueError("QINIU_BUCKET_DOMAIN 必须使用 HTTPS 下载域名")
+ if domain and not domain.startswith("https://"):
+ domain = f"https://{domain}"
+ hostname = (urlsplit(domain).hostname or "").lower()
+ labels = hostname.split(".")
+ if hostname.endswith(".qiniucs.com") and any(
+ label == "s3" or label.startswith("s3-") for label in labels
+ ):
+ raise ValueError(
+ "QINIU_BUCKET_DOMAIN 不能使用七牛 S3 API 端点;"
+ "请填写 Bucket 绑定的 HTTPS Kodo/CDN 下载域名"
+ )
+ return domain
settings = StorageSettings()
diff --git a/backend/packages/framework/src/windup_framework/db/.gitkeep b/backend/packages/framework/src/windup_framework/db/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/httpx_client/.gitkeep b/backend/packages/framework/src/windup_framework/httpx_client/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/logging/.gitkeep b/backend/packages/framework/src/windup_framework/logging/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/mq/.gitkeep b/backend/packages/framework/src/windup_framework/mq/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/providers/.gitkeep b/backend/packages/framework/src/windup_framework/providers/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py
index 3524bbf3..fd1f448e 100644
--- a/backend/packages/framework/src/windup_framework/providers/__init__.py
+++ b/backend/packages/framework/src/windup_framework/providers/__init__.py
@@ -1,8 +1,18 @@
-"""按模型能力划分的 AI Provider 接口。"""
+"""按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。"""
from windup_framework.config.provider import AIProviderSettings
from windup_framework.providers.chat import create_chat_model
from windup_framework.providers.image import create_image_client
+from windup_framework.providers.interfaces import (
+ ImageProvider,
+ MatteProvider,
+ VideoProvider,
+)
+from windup_framework.providers.matte import OnnxU2NetMatteProvider
+from windup_framework.providers.sufy import (
+ SufyImageProvider,
+ SufyVideoProvider,
+)
from windup_framework.providers.video import create_video_client
__all__ = [
@@ -10,4 +20,13 @@
"create_chat_model",
"create_image_client",
"create_video_client",
+ # 能力接口(ai_engine 依赖这些稳定契约)
+ "ImageProvider",
+ "VideoProvider",
+ "MatteProvider",
+ # 实现
+ "SufyVideoProvider",
+ # FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现
+ "SufyImageProvider",
+ "OnnxU2NetMatteProvider",
]
diff --git a/backend/packages/framework/src/windup_framework/providers/chat.py b/backend/packages/framework/src/windup_framework/providers/chat.py
index acf98709..bd9bc4e3 100644
--- a/backend/packages/framework/src/windup_framework/providers/chat.py
+++ b/backend/packages/framework/src/windup_framework/providers/chat.py
@@ -15,10 +15,19 @@ def create_chat_model(
这里仅统一 Windup 配置到 LangChain 官方客户端的映射,不重新实现
``BaseChatModel``、消息转换、工具调用或结构化输出。
+
+ 空 ``AI_API_KEY`` 或空型号直接拒绝,避免 langchain-openai 1.4 抛
+ ``OpenAIError`` 或留下 ``ChatOpenAI(model="")``。
"""
+ model = (config.chat_model or config.model or "").strip()
+ api_key = (config.api_key or "").strip()
+ if not api_key:
+ raise ValueError("AI_API_KEY 未配置")
+ if not model:
+ raise ValueError("AI_CHAT_MODEL / AI_MODEL 未配置")
return ChatOpenAI(
- model=config.model,
- api_key=config.api_key or None,
+ model=model,
+ api_key=api_key,
base_url=config.normalized_base_url,
timeout=config.timeout,
max_retries=config.max_retries,
diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py
new file mode 100644
index 00000000..4addf0f0
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py
@@ -0,0 +1,43 @@
+"""AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。
+
+ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。
+实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测
+到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。
+
+本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。
+"""
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class ImageProvider(Protocol):
+ """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。"""
+
+ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ...
+
+
+@runtime_checkable
+class VideoProvider(Protocol):
+ """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。
+
+ **入参恒为 bytes,不是 URL。** 上游(ai_engine.strategy)手里只有母版 bytes,而且它
+ 必须有 bytes —— ``master_check`` 预检、``master_prep`` 预处理、像素化锁色板全都读
+ 母版**像素**。让调用点改传 URL 的话,ai_engine 还得自己下载回 bytes 才能干活。
+
+ 某些供应商的接口只吃公网 URL。那属于**该 provider 自己的适配问题**:在 provider
+ 内部完成 bytes → URL 的转换(需要一个上传能力时由组装层注入),而不是把这个差异
+ 漏给上层。这样"用哪个厂商"不会改变 ai_engine 的一行代码。
+ """
+
+ def i2v(
+ self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720"
+ ) -> bytes: ...
+
+
+@runtime_checkable
+class MatteProvider(Protocol):
+ """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。"""
+
+ def cutout(self, frame: bytes) -> bytes: ...
diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py
new file mode 100644
index 00000000..2f3a959e
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/matte.py
@@ -0,0 +1,254 @@
+"""主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。
+
+为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python
+3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认
+``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖,
+3.12 干净可装、可进 lock。同模型(u2netp),同质量。
+
+模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。
+onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。
+"""
+from __future__ import annotations
+
+import io
+import urllib.request
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+from .interfaces import MatteProvider
+
+# u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。
+_U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx"
+_DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx"
+
+# u2net 预处理常量(与 rembg 一致)。
+_MEAN = (0.485, 0.456, 0.406)
+_STD = (0.229, 0.224, 0.225)
+_SIZE = (320, 320)
+
+
+# 只清理"几乎精确等于底色"的像素。阈值必须窄:2026-08-07 实测,一个铁锈橙毛
+# (222,130,70)的角色配玫红底(222,41,124),两者红通道完全相同、欧氏距离仅 104 ——
+# 宽阈值会把毛判成半透明并去"反解",越解越坏(先成橄榄绿再成亮绿)。橙毛 d≈117,
+# 阈值 38 完全碰不到它;而闭合空隙里的背景 d≈0,能干净移除。
+# 杀伤半径不是常数,由实测的底色噪声推出来 —— 见 _kill_radius。下面两个是它的上下限。
+#
+# 固定阈值 38 抠穿过浅色角色:骨白角色到白底的距离只有 28.7,整块本体被判成背景;
+# 39~52 这一档不被杀但整块变半透明,角色发虚而不报任何异常。深色角色距离 300 以上,
+# 永远不沾这个窗口 —— 所以症状只出现在浅色角色身上。
+_KEY_KILL_MIN = 6.0 # 下限:再干净的底也留一点余量,否则压缩噪声会漏清
+_KEY_KILL_MAX = 24.0 # 上限:真空隙就是渲染出来的底色本身,不需要比这更宽
+_KEY_NOISE_K = 4.0 # 半径 = 底色噪声标准差 × 此系数
+_KEY_SOFT = 14.0 # 到杀伤半径 + 此值之间线性过渡,避免硬边锯齿
+_BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理
+
+# 采样前先丢掉最外圈像素。视频帧的最外一两行/列常是**编码器边缘伪影**,不是底色:
+# 2026-08-10 实测 9 段真 i2v 视频 × 16 帧 = 144 帧,贴边采样时 26 帧(18%)判"底不均匀"
+# 而跳过清理,逐一查证全部由最外圈造成 —— 白底母版视频最右一列整列纯黑(std 50.4),
+# 待机视频最顶一行偏暗(std 8.4,恰好压线越过 8)。往里让 1 px 就降到 1.9、让 2 px 降到 1.88,
+# 144 帧零误跳;三张静态母版的取样中位色一个字节都没变(220/64/135、222/39/130、222/41/124)。
+# 取 2 是为容下 2 px 宽的边框;真正不均匀的底(噪声/渐变/拼色)让多少都照样超阈值,守卫不松。
+_EDGE_SKIP = 2
+_CORNER = 12 # 每个角的采样块边长
+
+
+def _corner_pixels(rgb: np.ndarray) -> np.ndarray:
+ """四角采样块(跳过最外圈 ``_EDGE_SKIP`` 像素)拼成的 (N, 3) 像素表。
+
+ 图太小时(四角会互相重叠)不让,退回贴边取 —— 合成测试图和缩略图走这条路。
+ """
+ k = _CORNER
+ s = _EDGE_SKIP if min(rgb.shape[:2]) > 2 * (_EDGE_SKIP + k) else 0
+ r = rgb[s : rgb.shape[0] - s, s : rgb.shape[1] - s] if s else rgb
+ return np.concatenate([
+ r[:k, :k].reshape(-1, 3), r[:k, -k:].reshape(-1, 3),
+ r[-k:, :k].reshape(-1, 3), r[-k:, -k:].reshape(-1, 3),
+ ])
+
+
+# 空洞填充用。_HOLE_ALPHA:低于此 alpha 才算"透明",参与空洞判定。
+# _HOLE_BG_TOL:到底色的距离低于此值 → 判为"确实是底色"。取值依据(2026-08-11 实测,
+# 1280×720 真实视频帧):纯背景区域的色距 p99.9≈6.5、最大 11.1(视频压缩噪点);
+# 而被误杀的浅肤色像素连通域中位色距 ≥17.1。14 落在这条 1.5 倍间隙里。
+_HOLE_ALPHA = 0.03
+_HOLE_BG_TOL = 14.0
+
+
+def _bg_key(rgb: np.ndarray) -> np.ndarray | None:
+ """四角取样估底色 key;底不够均匀(std 超阈值)时返回 None = 不做任何基于底色的判断。
+
+ 抽成独立函数是为了让"底色是什么"只有一个真相源 —— 键控清理(``_flat_bg_penalty``)
+ 和空洞填充(``_fill_enclosed_holes``)必须按同一个 key 判断,否则一个把某块当背景
+ 清掉、另一个又把它当主体填回来,互相打架。取样统一走 :func:`_corner_pixels`,
+ 连"跳过最外圈编码器伪影"这条也只有一份实现。
+ """
+ corners = _corner_pixels(rgb) # 跳过编码器边缘伪影,见 _EDGE_SKIP
+ if float(corners.std(axis=0).max()) > _BG_FLAT_STD:
+ return None
+ return np.median(corners, axis=0).astype(np.float32)
+
+
+def _spread(seed: np.ndarray, region: np.ndarray) -> np.ndarray:
+ """在 ``region`` 内从 ``seed`` 出发做 4-邻接连通扩散,返回可达集合。
+
+ 为什么不写逐像素 BFS:交付前的帧是 1280×720(约 92 万像素),纯 Python BFS 要几十秒,
+ 抠图是逐帧调用的,扛不住。这里按**行/列游程**传播 —— 一个 pass 就能把可达性推过
+ 整条连续游程(距离不限),而不是每 pass 只推进一个像素,真实角色轮廓几个 pass 收敛。
+
+ 同一行里被非 region 像素隔断的两段游程,``cumsum(~region)`` 必然取到不同的 id,
+ 因此可以用 ``bincount`` 一次算出"每条游程里有没有种子"。
+ """
+ reach = seed & region
+ while True:
+ before = int(reach.sum())
+ for transposed in (False, True):
+ reg = region.T if transposed else region
+ rch = reach.T if transposed else reach
+ rows, cols = reg.shape
+ run = np.cumsum(~reg, axis=1)
+ keys = run + np.arange(rows)[:, None] * (cols + 1)
+ hit = np.bincount(keys[rch], minlength=rows * (cols + 1)) > 0
+ new = reg & hit[keys]
+ reach = new.T if transposed else new
+ if int(reach.sum()) == before:
+ return reach
+
+
+def _fill_enclosed_holes(alpha: np.ndarray, rgb: np.ndarray) -> np.ndarray:
+ """把"被主体围住、且整块都不是底色"的透明连通域填回主体(alpha=1)。
+
+ 要解决的问题:u2netp 判错或键控误杀会在主体内部留下透明洞,放大看是背景直接透出来。
+
+ **为什么只判"不与边界连通"不够 —— 会把两腿之间填实。** 直觉上腿间空隙从下方通到
+ 画面底边,所以"从边界出发的连通域"就能保护它。2026-08-11 在真实走路帧上实测:
+ **不成立**。迈步相里两只靴子在下方交叠,把腿间空隙彻底封死 —— 它就是一块不与边界
+ 连通的背景域(实测 src_017 有 530 像素、归档 frame_03 有 129 像素),只按连通性判,
+ 这一整块会被填成主体,两条腿直接焊在一起。
+
+ 所以判据是**连通性 + 颜色**两条一起:一个透明连通域只要"碰到画面边界"或者"里面
+ 存在任何一个确实是底色的像素",就不是洞。腿间空隙整块就是底色(实测中位色距 6.2,
+ 远低于 _HOLE_BG_TOL),必然被这条否决;而被误杀的主体像素(实测中位色距 ≥17.1)
+ 不含底色像素,才会被填。两条否决合成一次扩散:种子 = 边界上的透明像素 ∪ 底色像素。
+
+ 与 ``_flat_bg_penalty`` 的分工:那个函数按颜色**做减法**(把闭合空隙里的底色清掉),
+ 这个函数按颜色**决定不加回来** —— 同一个 key、同一个方向,不会互相拆台。
+ """
+ key = _bg_key(rgb)
+ if key is None:
+ return alpha # 底不是纯色 → 无从判断哪块是真空隙,一律不填
+ transparent = alpha < _HOLE_ALPHA
+ if not transparent.any():
+ return alpha
+ border = np.zeros_like(transparent)
+ border[0, :] = border[-1, :] = True
+ border[:, 0] = border[:, -1] = True
+ is_bg_color = np.linalg.norm(rgb - key, axis=2) < _HOLE_BG_TOL
+ seed = transparent & (border | is_bg_color)
+ holes = transparent & ~_spread(seed, transparent)
+ if not holes.any():
+ return alpha
+ out = alpha.copy()
+ out[holes] = 1.0
+ return out
+
+
+def _kill_radius(rgb: np.ndarray) -> float:
+ """按四角实测的底色噪声定杀伤半径,而不是取一个固定常数。
+
+ 要清掉的是**渲染出来的底色本身**(被主体围住的那块空隙),它与四角同源,差异只来自
+ 压缩噪声,量级由四角的离散度直接给出。固定常数没有这个信息,取宽了就会吃掉与底色
+ 接近的角色像素 —— 浅色角色正是落在那个窗口里。
+
+ 上限的意义:底噪再大也不该把半径放到能吞掉主体的程度;超过上限时宁可少清一点,
+ 留下的底色是可见的脏边,而抠穿角色是不可逆的破坏。
+ """
+ spread = float(_corner_pixels(rgb).std(axis=0).max())
+ return float(np.clip(spread * _KEY_NOISE_K, _KEY_KILL_MIN, _KEY_KILL_MAX))
+
+
+def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray:
+ """底色清理系数(0=纯背景,1=主体),形状与图同宽高。
+
+ 为什么需要它:u2netp 是显著性模型,对**闭合区域**天然失灵 —— 四足角色腿间的
+ 背景是一块被主体围住的空隙,显著性把它当成主体内部,整块底色留在产物里
+ (2026-08-07 实测)。而母版底色是刻意生成的纯色,均匀度极高(实测四角标准差 1.0~1.2),
+ 用它做一次窄阈值清理就能补上这个洞。
+
+ 与"按颜色抠是死路"那条规则的边界:那条说的是**拿颜色当主体判据**。这里主体判据仍然是
+ u2netp,颜色只用来做减法。但减法同样会减在角色身上 —— 本函数是乘性惩罚、作用于全图,
+ 与底色足够接近的**主体内部**像素照样归零。所以杀伤半径必须窄到只覆盖底色自身的噪声,
+ 由 :func:`_kill_radius` 按四角离散度推出。底色不够均匀时(std 超阈值)返回全 1,不清理。
+
+ **逐帧独立采样是安全的**(2026-08-10 在真视频帧上验证):同一段视频里逐帧算出的 key 色
+ 几乎不动(9 段 i2v 实测帧间位移 <= 1.73/255),故不需要跨帧共享一次采样。序列帧真正的
+ 闪烁源是**守卫在序列中途翻转**(部分帧清、部分帧不清):待机那段 16 帧里前 6 帧清、后 10 帧
+ 不清,主体面积逐帧变化 CV 从 0.0036 跳到 0.0197、第 6 帧单帧跳 4.25%。跳过最外圈后
+ 守卫不再翻转,CV 回到 0.0028 —— 比完全不清理还稳(清理同时抹掉了会自己抖的底色描边)。
+ """
+ key = _bg_key(rgb)
+ if key is None:
+ return np.ones(rgb.shape[:2], dtype=np.float32) # 底不是纯色 → 不动
+ d = np.linalg.norm(rgb - key, axis=2)
+ return np.clip((d - _kill_radius(rgb)) / _KEY_SOFT, 0.0, 1.0).astype(np.float32)
+
+
+class OnnxU2NetMatteProvider(MatteProvider):
+ """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。"""
+
+ def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None:
+ self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE
+ self._model_url = model_url
+ self._session = None # 惰性
+
+ def _ensure_model(self) -> Path:
+ if not self._model_path.exists():
+ self._model_path.parent.mkdir(parents=True, exist_ok=True)
+ urllib.request.urlretrieve(self._model_url, self._model_path)
+ return self._model_path
+
+ def _get_session(self):
+ if self._session is None:
+ try:
+ import onnxruntime as ort # 惰性:导入慢
+ except ImportError as e: # pragma: no cover - 取决于安装环境
+ # **不静默降级。** 这里曾在 ImportError 时回落到"取四角主色做 chroma-key",
+ # 有两个问题:①猜背景色 —— 白底母版四角就是白色,浅色角色(骨白/银甲)与背景
+ # 撞色会被抠穿;②静默 —— 开发机上看着能跑、输出其实是坏的,要到产物验收才发现。
+ raise RuntimeError(
+ "onnxruntime 不可用,无法做主体抠图。请安装 onnxruntime"
+ "(注意 <1.24 才有 macOS Intel 轮子)。"
+ ) from e
+ self._session = ort.InferenceSession(
+ str(self._ensure_model()), providers=["CPUExecutionProvider"]
+ )
+ return self._session
+
+ def _predict_mask(self, img: Image.Image) -> Image.Image:
+ """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。"""
+ im = img.convert("RGB").resize(_SIZE, Image.LANCZOS)
+ ary = np.array(im).astype(np.float32)
+ ary = ary / max(float(ary.max()), 1e-6)
+ tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32)
+ for c in range(3):
+ tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c]
+ tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32)
+
+ session = self._get_session()
+ pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :]
+ mi, ma = float(pred.min()), float(pred.max())
+ pred = (pred - mi) / max(ma - mi, 1e-6)
+ mask = (pred.squeeze() * 255).astype(np.uint8)
+ return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS)
+
+ def cutout(self, frame: bytes) -> bytes:
+ img = Image.open(io.BytesIO(frame)).convert("RGBA")
+ rgb = np.asarray(img.convert("RGB"), dtype=np.float32)
+ alpha = np.asarray(self._predict_mask(img), dtype=np.float32) / 255.0
+ alpha = alpha * _flat_bg_penalty(rgb)
+ alpha = _fill_enclosed_holes(alpha, rgb)
+ out = np.dstack([np.asarray(img.convert("RGB")), alpha * 255.0]).astype(np.uint8)
+ buf = io.BytesIO()
+ Image.fromarray(out, "RGBA").save(buf, "PNG")
+ return buf.getvalue()
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/__init__.py b/backend/packages/framework/src/windup_framework/providers/render3d/__init__.py
new file mode 100644
index 00000000..e331b8f0
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/__init__.py
@@ -0,0 +1,66 @@
+"""三渲二三段能力的 provider —— **产品侧 Protocol 形状**,为整体搬进产品仓准备。
+
+ 母版图 bytes ──Model3DProvider──▶ 3D 模型 bytes ──AutoRigProvider──▶ 绑骨模型 bytes
+ │
+ SpriteRenderProvider │
+ ▼
+ 各朝向序列帧 PNG bytes
+
+对应产品仓的落点(搬过去时是拆分而不是重写):
+
+ interfaces.py → framework/providers/interfaces.py (三个 Protocol + ModelUploader port)
+ tencent.py → framework/providers/tencent3d.py (两个计费实现 + COS uploader)
+ sprite.py → framework/providers/sprite3d.py (本地出帧实现)
+ checks.py → ai_engine/model_check.py (与 master_check 并列的入口预检)
+ _tc3.py → 产品仓若已有腾讯云凭证层则整份替换
+
+**本包不 import 管线仓任何模块**,依赖只有标准库 + 出帧段的 node/three/playwright。
+"""
+from ._tc3 import TencentApiError, TencentCredentials, redact
+from .checks import ModelFacts, check_model, sniff_format
+from .interfaces import (
+ RENDER_SIZE,
+ ArtifactFormatError,
+ AutoRigProvider,
+ InsufficientCreditsError,
+ JobFailedError,
+ JobTimeoutError,
+ Model3DProvider,
+ ModelNotPublicError,
+ ModelRejectCode,
+ ModelRejected,
+ ModelUploader,
+ PresetMotion,
+ RenderStageError,
+ RigInfo,
+ RiggedModel,
+ SpriteRenderProvider,
+ SpriteSequence,
+ SpriteSheet,
+)
+from .sprite import DIRECTIONS_4, DIRECTIONS_8, MATERIALS, LocalSpriteRenderProvider
+from .tencent import (
+ PRESET_MOTIONS,
+ SpendNotAuthorizedError,
+ TencentAutoRigProvider,
+ TencentCosModelUploader,
+ TencentModel3DProvider,
+)
+
+__all__ = [
+ "RENDER_SIZE",
+ # 契约
+ "Model3DProvider", "AutoRigProvider", "SpriteRenderProvider", "ModelUploader",
+ "PresetMotion", "RiggedModel", "SpriteSequence", "SpriteSheet", "RigInfo",
+ "ModelFacts", "ModelRejectCode",
+ # 实现
+ "TencentModel3DProvider", "TencentAutoRigProvider", "TencentCosModelUploader",
+ "LocalSpriteRenderProvider",
+ # 预检 / 常量
+ "check_model", "sniff_format", "PRESET_MOTIONS", "DIRECTIONS_4", "DIRECTIONS_8",
+ "MATERIALS", "TencentCredentials", "redact",
+ # 出错形态
+ "ModelRejected", "ArtifactFormatError", "ModelNotPublicError", "JobFailedError",
+ "JobTimeoutError", "InsufficientCreditsError", "RenderStageError",
+ "SpendNotAuthorizedError", "TencentApiError",
+]
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/_tc3.py b/backend/packages/framework/src/windup_framework/providers/render3d/_tc3.py
new file mode 100644
index 00000000..8837cdce
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/_tc3.py
@@ -0,0 +1,188 @@
+"""腾讯云 TC3-HMAC-SHA256 / COS 签名与 HTTP —— **零依赖,只用标准库**。
+
+自带一份而不是 import 管线仓的 ``pipeline.tencent_sign``:整个包要能一次性搬进产品仓,
+带着一条对管线私有模块的 import 就搬不动。产品仓若已有自己的腾讯云凭证层,
+搬过去时把本文件整份换掉即可 —— 上层只用到 :class:`TencentCredentials` 与 :func:`call`。
+
+凭证:环境变量优先,其次 ``~/.config/windup/tencent.env``(600)。**绝不硬编码、绝不进 git、
+绝不进日志** —— 注意 COS 预签名 URL 里带着 ``q-ak=`` 与 ``q-signature``,
+所以任何要把 URL 拼进异常/日志的地方都必须先过 :func:`redact`。
+"""
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import os
+import pathlib
+import re
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+
+__all__ = ["TencentCredentials", "call", "redact", "TencentApiError"]
+
+ENVFILE = pathlib.Path.home() / ".config" / "windup" / "tencent.env"
+
+# 预签名 URL 里会出现的敏感查询参数。redact 只保留形状,不保留值。
+_SECRET_QS = re.compile(r"(q-ak|q-signature|q-sign-time|q-key-time|Signature|SecretId)=[^&\s]*")
+
+
+def redact(text: str) -> str:
+ """把签名 / SecretId 从任意文本里抹掉,再往日志或异常里放。
+
+ 存在的理由很具体:COS 预签名 URL 的 ``q-ak`` **就是 SecretId**。一条"下载失败:
+ https://...q-ak=AKID...&q-signature=..." 的错误日志等于把半副凭证写进了日志文件。
+ """
+ return _SECRET_QS.sub(lambda m: m.group(0).split("=", 1)[0] + "=", text)
+
+
+class TencentApiError(RuntimeError):
+ """腾讯云返回了 ``Response.Error``。``code`` 是 ``Error.Code`` 原文。"""
+
+ def __init__(self, code: str, message: str) -> None:
+ super().__init__(f"{code}: {message}")
+ self.code = code
+ self.message = message
+
+
+@dataclass(frozen=True)
+class TencentCredentials:
+ """腾讯云凭证。``repr=False`` 是有意的 —— dataclass 的默认 repr 会把 key 打出来,
+ 而 provider 出错时的 traceback 常常带上构造参数。"""
+
+ secret_id: str = field(repr=False)
+ secret_key: str = field(repr=False)
+ region: str = "ap-guangzhou"
+
+ @classmethod
+ def resolve(cls, region: str | None = None) -> TencentCredentials:
+ """环境变量 → 加锁文件。两处都没有就抛,不静默用空串(空串会得到一个
+ 看不懂的鉴权错,而不是"你没配凭证")。"""
+ sid = os.environ.get("TENCENT_SECRET_ID", "")
+ skey = os.environ.get("TENCENT_SECRET_KEY", "")
+ if not (sid and skey) and ENVFILE.exists():
+ kv = {}
+ for line in ENVFILE.read_text().splitlines():
+ if "=" in line and not line.lstrip().startswith("#"):
+ k, v = line.split("=", 1)
+ # 去掉包裹引号:.env 里写 KEY="AKID..." 是常见写法,带引号的值
+ # 会变成一个看不懂的鉴权错,而错在哪一层完全看不出来。
+ kv[k.strip()] = v.strip().strip("\"'")
+ sid = sid or kv.get("TENCENT_SECRET_ID", "")
+ skey = skey or kv.get("TENCENT_SECRET_KEY", "")
+ if not (sid and skey):
+ raise RuntimeError(
+ "缺腾讯云凭证:请设 TENCENT_SECRET_ID / TENCENT_SECRET_KEY,"
+ f"或写入 {ENVFILE}(chmod 600)"
+ )
+ return cls(sid, skey, region or os.environ.get("TENCENT_REGION", "ap-guangzhou"))
+
+
+def _hmac(key: bytes, msg: str) -> bytes:
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
+
+
+def call(action: str, params: dict, *, service: str, version: str,
+ creds: TencentCredentials, retries: int = 3, timeout: int = 60,
+ idempotent: bool = False) -> dict:
+ """调一个腾讯云接口,返回 ``Response`` 体(含 ``Error`` 时原样返回,由调用方判断)。
+
+ 重试范围按"重发一次的代价"划,不按"看起来像不像临时故障"划:
+ 网络/超时与 429 限流一律重试(请求没被执行);网关 5xx 只在 ``idempotent`` 时重试
+ —— 它意味着请求可能已经到达后端,提交类重发会重复扣积分。业务错误从不重试。
+ """
+ host = f"{service}.tencentcloudapi.com"
+ payload = json.dumps(params, ensure_ascii=False)
+ ct = "application/json; charset=utf-8"
+
+ last: Exception | None = None
+ for attempt in range(retries):
+ ts = int(time.time())
+ date = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
+ canonical = "\n".join([
+ "POST", "/", "",
+ f"content-type:{ct}\nhost:{host}\nx-tc-action:{action.lower()}\n",
+ "content-type;host;x-tc-action",
+ hashlib.sha256(payload.encode()).hexdigest(),
+ ])
+ scope = f"{date}/{service}/tc3_request"
+ to_sign = "\n".join(["TC3-HMAC-SHA256", str(ts), scope,
+ hashlib.sha256(canonical.encode()).hexdigest()])
+ k = _hmac(("TC3" + creds.secret_key).encode(), date)
+ k = _hmac(k, service)
+ k = _hmac(k, "tc3_request")
+ sig = hmac.new(k, to_sign.encode(), hashlib.sha256).hexdigest()
+
+ req = urllib.request.Request(
+ f"https://{host}",
+ data=payload.encode(),
+ headers={
+ "Authorization": (f"TC3-HMAC-SHA256 Credential={creds.secret_id}/{scope}, "
+ f"SignedHeaders=content-type;host;x-tc-action, "
+ f"Signature={sig}"),
+ "Content-Type": ct, "Host": host,
+ "X-TC-Action": action, "X-TC-Timestamp": str(ts),
+ "X-TC-Version": version, "X-TC-Region": creds.region,
+ },
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return json.loads(r.read().decode()).get("Response", {})
+ except urllib.error.HTTPError as e:
+ # 腾讯云的**业务**错误走 HTTP 200 + Response.Error,到不了这里;能到这里的
+ # 只有网关层的状态码。两者的安全性不同,不能一起处理:
+ # 429 限流 = 请求被网关挡下、后端没执行 → 重发安全,提交类也不会重复扣费。
+ # 5xx = 请求**可能已经到达后端**并建了任务 → 提交类重发会重复扣积分,
+ # 只有幂等调用(查询 / 取件)才可以重试。
+ # 此前一律不重试,于是 5xx 的空响应经 `.get("Response", {})` 变成 {},
+ # 一路走到 `_raise_for_error({})` 返回 {},最后以"没拿到 JobId"的面目出现 ——
+ # 一次网关抖动被报成业务失败,排查方向整个跑偏。
+ if e.code == 429 or (idempotent and e.code >= 500):
+ last = e
+ time.sleep(2 + attempt * 2)
+ continue
+ return json.loads(e.read().decode()).get("Response", {})
+ except Exception as e: # 网络抖动才重试
+ last = e
+ time.sleep(2 + attempt * 2)
+ raise last # type: ignore[misc]
+
+
+# ── COS(对象存储)最小客户端 ────────────────────────────────────────────────
+# 存在的理由:绑骨的 ``File3D.Url`` 只接受**公网可拉取的 URL**,本地路径和 base64 都不行。
+# 桶保持私有,用预签名 URL 给限时读取权限 —— 不开公有读,避免模型资产长期裸奔。
+
+
+def cos_sign(creds: TencentCredentials, method: str, uri: str, host: str,
+ expire: int = 3600) -> str:
+ """COS 请求签名(只把 host 纳入签名头,与实际请求保持一致)。
+
+ 注意签名与 **HTTP 方法**绑定:签的是 GET 就只能 GET;拿 HEAD 去验会 403,
+ 验请用 GET(可加 Range 只取头几字节)。
+ """
+ now = int(time.time())
+ key_time = f"{now - 60};{now + int(expire)}"
+ sign_key = hmac.new(creds.secret_key.encode(), key_time.encode(), hashlib.sha1).hexdigest()
+ http_string = f"{method.lower()}\n{uri}\n\nhost={urllib.parse.quote(host, safe='')}\n"
+ to_sign = f"sha1\n{key_time}\n{hashlib.sha1(http_string.encode()).hexdigest()}\n"
+ sig = hmac.new(sign_key.encode(), to_sign.encode(), hashlib.sha1).hexdigest()
+ return (f"q-sign-algorithm=sha1&q-ak={creds.secret_id}&q-sign-time={key_time}"
+ f"&q-key-time={key_time}&q-header-list=host&q-url-param-list=&q-signature={sig}")
+
+
+def cos_request(creds: TencentCredentials, method: str, uri: str, host: str,
+ data: bytes | None = None, timeout: int = 300) -> tuple[int, str]:
+ req = urllib.request.Request(
+ f"https://{host}{uri}", data=data,
+ headers={"Authorization": cos_sign(creds, method, uri, host), "Host": host},
+ method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.status, r.read().decode("utf-8", "replace")[:500]
+ except urllib.error.HTTPError as e:
+ return e.code, e.read().decode("utf-8", "replace")[:500]
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/checks.py b/backend/packages/framework/src/windup_framework/providers/render3d/checks.py
new file mode 100644
index 00000000..129a116f
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/checks.py
@@ -0,0 +1,326 @@
+"""绑骨入口预检 —— **在花钱之前**允许拒绝的那道闸。
+
+对应 ``ai_engine.master_check`` 之于母版:那一层挡的是"喂了一张画错的图,全程无一处报错,
+产出 16 帧不会走路的错角色,钱已花完才发现";这一层挡的是绑骨接口的三条**硬约束** ——
+
+ ① 格式 GLB / FBX,**≤60MB**;
+ ② 须 A-Pose 或 T-Pose;
+ ③ **不得含人体以外的组件(武器、配件)**。
+
+三条的共同点是:**违反了接口不会报错,只会默默产出错结果**。实测送入带剑的模型后,
+剑被错误绑上权重、动画里到处乱甩 —— 任务"成功"、积分照扣。
+
+**本层判什么(全部本地零成本、可复现)**
+
+ - 能不能读 —— 不是 GLB 也不是 FBX 的 bytes(截断 / 传错文件)直接拒;
+ - 体积 —— 精确可量,硬拒;
+ - 姿势 —— **单侧近似**:量臂展/身高比,只在"明显不是 A/T-Pose"时拒。见
+ :data:`ARM_SPAN_MIN` / :data:`ARM_SPAN_MAX` 的推导与标定说明;
+ - 配件 —— **弱信号**:网格 / 节点 / 材质名里出现武器词才拒。见 :data:`ACCESSORY_TOKENS`。
+
+**本层判不了什么 —— 别当成已经守住了**
+
+ - **融进同一块网格的武器**。图生 3D 出的是**单块网格**:母版图里画了剑,剑就和身体
+ 焊在同一个 mesh 里,既没有独立节点也没有独立材质名。这种情况本层**一个信号都没有**,
+ 只能靠上游(母版图不画武器)保证 —— 我们的武器走刚体挂件、绑完骨再挂到手骨,天然合规。
+ - **"这是不是 A/T-Pose"的正面判定**。臂展比只能证伪不能证实:一个双手前平举的姿势
+ 臂展比可以完全正常,但它不是 A/T-Pose。要真正判定得读骨骼 / 用视觉模型,那是另一件事。
+ - **FBX 的姿势与配件**。二进制 FBX 的顶点数据在 zlib 压缩的节点记录里,没有 glTF
+ ``accessor.min/max`` 那样白送的包围盒;为它写一个解析器不划算,因为**送检的本来就是
+ 图生 3D 出的 GLB**(FBX 是绑骨的产物、不是入参)。FBX 只做格式与体积检查,
+ :attr:`ModelFacts.geometry_read` 会是 ``False`` —— 那两条这时候**测不了,只能靠调用方保证**。
+
+纯标准库,零依赖,不联网。
+"""
+from __future__ import annotations
+
+import json
+import re
+import struct
+from collections.abc import Iterable, Mapping, Sequence
+from dataclasses import dataclass, field
+
+from .interfaces import ModelRejectCode, ModelRejected
+
+__all__ = [
+ "MAX_UPLOAD_BYTES", "ARM_SPAN_MIN", "ARM_SPAN_MAX", "ACCESSORY_TOKENS",
+ "ModelFacts", "sniff_format", "check_model", "read_glb_geometry",
+]
+
+# 接口上限 60MB。按 **10^6** 而不是 2^20 算 —— 文档只写"60MB",两种口径差 4.7%,
+# 取小的那个:多拒 4.7% 的边界档,好过送上去以后默默产出错结果。
+MAX_UPLOAD_BYTES = 60 * 10**6
+
+# 臂展(横向最大跨度) / 身高 的容许区间。**单侧使用**:落在区间内不代表是 A/T-Pose,
+# 落在区间外基本可以断定不是。
+#
+# 下限 0.45 的来路(解剖几何,不是拍的):
+# T-Pose 臂展 ≈ 身高(比值 ≈1.0);A-Pose(手臂约 45° 下垂)≈ 0.55–0.75;
+# 手臂完全贴身垂下 ≈ 0.30–0.35(肩宽约 0.25H + 手臂厚度)。0.45 落在两群之间,
+# 两边都留了余量。
+# **标定样本 n=1**:本仓唯一一个"送进绑骨并成功"的档 model_std_draw15k.glb 量得 0.715
+# (X 跨 0.7166 / Y 跨 1.0020)。一个样本谈不上统计标定,所以阈值取解剖推导、
+# 用这个样本证伪"阈值定高了",而不是拿它去拟合。
+ARM_SPAN_MIN = 0.45
+
+# 上限 1.6:没有哪个人形 A/T-Pose 的横向跨度能到身高的 1.6 倍。超了通常意味着
+# **模型是躺着的 / 轴向约定不对**(glTF 规范是 Y-up,但从 Z-up 工具导出的档会把身高
+# 放到 Z 轴上,此时 max(X,Z)/Y 会冲到 3 以上)。这一条挡的是"仪器/坐标系错了",
+# 与姿势本身无关 —— 但后果一样:送上去照样扣积分、照样产出错结果。
+ARM_SPAN_MAX = 1.6
+
+# 名字里出现这些词就当带武器配件。**高精度低召回**:命中基本不会错(谁会把躯干命名成
+# sword),漏掉的多 —— 融进主网格的武器一个都逮不到(见模块 docstring)。
+ACCESSORY_TOKENS = (
+ "sword", "blade", "katana", "dagger", "knife", "axe", "spear", "lance",
+ "staff", "wand", "bow", "arrow", "quiver", "gun", "rifle", "pistol",
+ "shield", "weapon", "hammer", "scythe", "prop",
+ "剑", "刀", "枪", "矛", "斧", "盾", "弓", "杖", "武器", "配件", "挂件",
+)
+_TOKEN_RE = re.compile("|".join(re.escape(t) for t in ACCESSORY_TOKENS), re.IGNORECASE)
+
+_GLB_MAGIC = b"glTF"
+_FBX_MAGIC = b"Kaydara FBX Binary"
+
+
+@dataclass(frozen=True)
+class ModelFacts:
+ """预检**量到**的模型形态。
+
+ 通过时也返回它(而不是只返 None),理由与 ``MasterFacts`` 一样:数进进度文案,
+ 出问题(尤其是误拒)时一眼看得出当时把什么当成了身高、臂展、配件。
+ """
+
+ fmt: str
+ size_bytes: int
+ geometry_read: bool # False = 没读到几何,姿势/配件两条**没测**
+ bbox: tuple[float, float, float] | None = None # (x, y, z) 跨度
+ arm_span_ratio: float | None = None # max(x, z) / y
+ mesh_nodes: int = 0
+ named_parts: Sequence[str] = field(default_factory=tuple)
+
+ def note(self) -> str:
+ mb = self.size_bytes / 10**6
+ if not self.geometry_read:
+ return (f"{self.fmt} {mb:.1f}MB;未读几何 —— 姿势/配件两条未检,由调用方保证")
+ assert self.bbox is not None
+ x, y, z = self.bbox
+ return (f"{self.fmt} {mb:.1f}MB;包围盒 {x:.3f}×{y:.3f}×{z:.3f},"
+ f"臂展/身高 {self.arm_span_ratio:.2f},网格节点 {self.mesh_nodes}")
+
+
+def sniff_format(model: bytes) -> str:
+ """按 **magic bytes** 判容器格式。
+
+ 为什么不接受调用方声明的 ``fmt=``:嗅探零成本且**不可能与事实矛盾**,而一个参数可以
+ 填错;填错的后果是接口按错误格式解析,产出错结果而不是报错。这一条同时是
+ :class:`ArtifactFormatError` 那个坑的另一半 —— 收产物时也用它复核供应商自述的 Type。
+ """
+ if model[:4] == _GLB_MAGIC:
+ return "GLB"
+ if model[:len(_FBX_MAGIC)] == _FBX_MAGIC:
+ return "FBX"
+ raise ModelRejected(
+ ModelRejectCode.UNREADABLE,
+ f"既不是 GLB(magic {_GLB_MAGIC!r})也不是二进制 FBX(magic {_FBX_MAGIC!r}),"
+ f"头 8 字节是 {model[:8]!r};长度 {len(model)} 字节",
+ )
+
+
+# ── GLB 几何 ────────────────────────────────────────────────────────────────
+
+
+def _glb_json(model: bytes) -> Mapping:
+ """取 GLB 的 JSON 块。**不碰 BIN 块** —— 包围盒在 accessor 的 min/max 里白送。"""
+ if len(model) < 12:
+ raise ModelRejected(ModelRejectCode.UNREADABLE, f"GLB 只有 {len(model)} 字节,连头都不够")
+ _, _, total = struct.unpack_from("<4sII", model, 0)
+ if total > len(model):
+ raise ModelRejected(
+ ModelRejectCode.UNREADABLE,
+ f"GLB 头声称 {total} 字节,实际只有 {len(model)} —— 文件被截断了",
+ )
+ off = 12
+ while off + 8 <= total:
+ clen, ctype = struct.unpack_from(" tuple[float, ...]:
+ """节点的局部变换,列主序 4×4(与 glTF 的 ``matrix`` 同序)。
+
+ 必须算:accessor 的 min/max 是**网格局部空间**的,节点上挂着缩放时直接拿来比就错了
+ (图生 3D 的产物常带一个整体缩放)。TRS 与 matrix 二选一,规范如此。
+ """
+ if "matrix" in node:
+ return tuple(float(v) for v in node["matrix"])
+ tx, ty, tz = node.get("translation", (0.0, 0.0, 0.0))
+ qx, qy, qz, qw = node.get("rotation", (0.0, 0.0, 0.0, 1.0))
+ sx, sy, sz = node.get("scale", (1.0, 1.0, 1.0))
+ # 四元数 → 3×3
+ r = (
+ 1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy + qz * qw), 2 * (qx * qz - qy * qw),
+ 2 * (qx * qy - qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz + qx * qw),
+ 2 * (qx * qz + qy * qw), 2 * (qy * qz - qx * qw), 1 - 2 * (qx * qx + qy * qy),
+ )
+ return (
+ r[0] * sx, r[1] * sx, r[2] * sx, 0.0,
+ r[3] * sy, r[4] * sy, r[5] * sy, 0.0,
+ r[6] * sz, r[7] * sz, r[8] * sz, 0.0,
+ tx, ty, tz, 1.0,
+ )
+
+
+def _mul(a: Sequence[float], b: Sequence[float]) -> tuple[float, ...]:
+ """列主序 4×4 相乘,返回 a∘b(先应用 b,再应用 a)。"""
+ out = []
+ for col in range(4):
+ for row in range(4):
+ out.append(sum(a[k * 4 + row] * b[col * 4 + k] for k in range(4)))
+ return tuple(out)
+
+
+def _apply(m: Sequence[float], p: Sequence[float]) -> tuple[float, float, float]:
+ return (
+ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
+ m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
+ m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
+ )
+
+
+_IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)
+
+
+def read_glb_geometry(model: bytes) -> ModelFacts:
+ """从 GLB 读包围盒 / 网格节点数 / 各处名字。只解 JSON 块,不解 BIN。"""
+ doc = _glb_json(model)
+ nodes = doc.get("nodes") or []
+ meshes = doc.get("meshes") or []
+ accessors = doc.get("accessors") or []
+ materials = doc.get("materials") or []
+
+ lo = [float("inf")] * 3
+ hi = [float("-inf")] * 3
+ mesh_nodes = 0
+ names: list[str] = []
+
+ scene = (doc.get("scenes") or [{}])[doc.get("scene", 0)] if doc.get("scenes") else {}
+ roots: Iterable[int] = scene.get("nodes") or range(len(nodes))
+
+ stack = [(int(i), _IDENTITY) for i in roots]
+ seen: set[int] = set()
+ while stack:
+ idx, parent = stack.pop()
+ if idx in seen or idx >= len(nodes): # 防环:坏文件里 children 自引会转死
+ continue
+ seen.add(idx)
+ node = nodes[idx]
+ world = _mul(parent, _node_matrix(node))
+ for child in node.get("children") or []:
+ stack.append((int(child), world))
+ if "mesh" not in node:
+ continue
+ mesh_nodes += 1
+ mesh = meshes[node["mesh"]] if node["mesh"] < len(meshes) else {}
+ for label in (node.get("name"), mesh.get("name")):
+ if label:
+ names.append(str(label))
+ for prim in mesh.get("primitives") or []:
+ mat = prim.get("material")
+ if mat is not None and mat < len(materials) and materials[mat].get("name"):
+ names.append(str(materials[mat]["name"]))
+ pos = (prim.get("attributes") or {}).get("POSITION")
+ if pos is None or pos >= len(accessors):
+ continue
+ acc = accessors[pos]
+ amin, amax = acc.get("min"), acc.get("max")
+ if not (amin and amax and len(amin) == 3):
+ continue
+ # 变换后的包围盒 = 8 个角点变换后再取包围盒(有旋转时取 min/max 直接变换是错的)
+ for cx in (amin[0], amax[0]):
+ for cy in (amin[1], amax[1]):
+ for cz in (amin[2], amax[2]):
+ p = _apply(world, (float(cx), float(cy), float(cz)))
+ for k in range(3):
+ lo[k] = min(lo[k], p[k])
+ hi[k] = max(hi[k], p[k])
+
+ if lo[0] == float("inf"):
+ # 有 GLB 结构但读不到任何 POSITION 边界。不当成"通过",标成没测。
+ return ModelFacts(fmt="GLB", size_bytes=len(model), geometry_read=False,
+ mesh_nodes=mesh_nodes, named_parts=tuple(names))
+
+ bbox = (hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2])
+ height = bbox[1]
+ ratio = (max(bbox[0], bbox[2]) / height) if height > 1e-9 else float("inf")
+ return ModelFacts(fmt="GLB", size_bytes=len(model), geometry_read=True, bbox=bbox,
+ arm_span_ratio=ratio, mesh_nodes=mesh_nodes, named_parts=tuple(names))
+
+
+# ── 闸 ──────────────────────────────────────────────────────────────────────
+
+
+def check_model(
+ model: bytes,
+ *,
+ max_bytes: int = MAX_UPLOAD_BYTES,
+ arm_span_min: float = ARM_SPAN_MIN,
+ arm_span_max: float = ARM_SPAN_MAX,
+ check_pose: bool = True,
+ check_accessories: bool = True,
+) -> ModelFacts:
+ """绑骨送检前的入口预检。通过返回量到的形态,不通过抛 :class:`ModelRejected`。
+
+ ``check_pose`` / ``check_accessories`` 留成开关,不是为了方便跳过 —— 是因为这两条
+ **都是近似判据**(一个单侧、一个弱信号),误拒的代价是挡住调用方一次合法的付费调用。
+ 关掉时后果由调用方承担,且 :attr:`ModelFacts.geometry_read` 仍如实记录量到了什么。
+ """
+ fmt = sniff_format(model)
+ if len(model) > max_bytes:
+ raise ModelRejected(
+ ModelRejectCode.TOO_LARGE,
+ f"{len(model) / 10**6:.1f}MB 超过接口上限 {max_bytes / 10**6:.0f}MB;"
+ "先本地减面(decimate)再送绑骨 —— 超限不会报错,只会产出错结果",
+ )
+ if fmt != "GLB":
+ # FBX:没有白送的包围盒,姿势/配件两条测不了。如实记成"没测",不冒充通过。
+ return ModelFacts(fmt=fmt, size_bytes=len(model), geometry_read=False)
+
+ facts = read_glb_geometry(model)
+ if not facts.geometry_read:
+ return facts
+
+ if check_accessories:
+ hit = [n for n in facts.named_parts if _TOKEN_RE.search(n)]
+ if hit:
+ raise ModelRejected(
+ ModelRejectCode.HAS_ACCESSORY,
+ f"网格/节点/材质名里有武器配件词:{hit};接口要求送检的是**去武器的身体档**"
+ "(实测带剑的模型,剑会被绑上权重、动画里乱甩)。武器请绑完骨再作为刚体挂件挂到手骨。",
+ )
+
+ if check_pose:
+ ratio = facts.arm_span_ratio
+ assert ratio is not None
+ if ratio < arm_span_min:
+ raise ModelRejected(
+ ModelRejectCode.NOT_A_POSE,
+ f"臂展/身高 = {ratio:.2f},低于下限 {arm_span_min};手臂是贴着身体垂下的,"
+ "不是 A-Pose 也不是 T-Pose。接口对非 A/T-Pose 不报错,只会绑出错骨架。",
+ )
+ if ratio > arm_span_max:
+ raise ModelRejected(
+ ModelRejectCode.NOT_A_POSE,
+ f"臂展/身高 = {ratio:.2f},高于上限 {arm_span_max};人形 A/T-Pose 不会这么宽 —— "
+ "多半是模型躺着或轴向约定不对(glTF 规范是 Y-up)。包围盒 "
+ f"{facts.bbox[0]:.3f}×{facts.bbox[1]:.3f}×{facts.bbox[2]:.3f}。",
+ )
+ return facts
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py b/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py
new file mode 100644
index 00000000..0fb50976
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/interfaces.py
@@ -0,0 +1,300 @@
+"""三渲二三段能力的 provider 接口契约(三渲二版的 ``framework/providers/interfaces.py``)。
+
+搬进产品仓时,本文件的三个 Protocol 与两个 port 直接并入
+``backend/packages/framework/src/windup_framework/providers/interfaces.py``,
+实现并入同目录的 ``tencent3d.py`` / ``sprite.py``。故这里**不 import 管线仓任何模块**。
+
+三段:
+
+ Model3DProvider 母版图 bytes → 3D 模型 bytes (云,按次计费)
+ AutoRigProvider 3D 模型 bytes → 绑骨模型 bytes + 动作 (云,按次计费)
+ SpriteRenderProvider 绑骨模型 bytes → 各朝向序列帧 bytes (本地,零成本)
+
+**入参恒为 bytes,不是 URL / 路径** —— 照抄 :class:`VideoProvider` 立下的约定:
+上游手里只有 bytes,让每个调用点自己想办法弄一个公网 URL,会把"对象存储"扩散到
+整条管线。绑骨接口(``SubmitAutoRiggingJob``)的 ``File3D.Url`` **只吃公网 URL、
+不吃 base64**,那是 :class:`AutoRigProvider` 实现自己的适配问题:它在构造时接一个
+:class:`ModelUploader`,在 provider 内部把 bytes 换成 URL。与 ``FalQueueVideoProvider``
+接 ``FirstFrameUploader`` 完全同构。
+
+出参同理:序列帧是 PNG bytes 而不是"一个目录",provider 不替调用方决定落盘位置。
+"""
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import Protocol, runtime_checkable
+
+__all__ = [
+ "Model3DProvider", "AutoRigProvider", "SpriteRenderProvider", "ModelUploader",
+ "PresetMotion", "RiggedModel", "SpriteSequence", "SpriteSheet", "RigInfo",
+ "ModelRejectCode", "ModelRejected", "ArtifactFormatError", "ModelNotPublicError",
+ "JobFailedError", "JobTimeoutError", "InsufficientCreditsError", "RenderStageError",
+ "MODEL_FORMATS",
+]
+
+# 接口两端都只认这两种容器格式(``File3D.Type`` 的取值域)。
+MODEL_FORMATS = ("GLB", "FBX")
+
+# 出帧画布默认竖屏 1536×2560(2026-08-11 实测定的口径)。
+# 旧默认 1107×924 是横的,而角色是竖长的:主体只有 193×668,宽度浪费 82%。
+# 同一模型同一动作只改画布,主体 193×668 → 512×1772(2.65 倍),而单帧耗时 2.5~2.6 秒
+# 几乎不变 —— 提分辨率是纯赚。"糊"的根因是画布太小,不是渲染质量差。
+# (与 i2v 那条线同一个病:横屏 1280×720 卡死竖长角色的高度,改竖屏后主体 157px→322px。)
+RENDER_SIZE = (1536, 2560)
+
+
+
+# ── 出错形态 ────────────────────────────────────────────────────────────────
+# 分得这么细不是洁癖:这条线上"看起来一样、修法完全不同"的失败太多了 ——
+# 积分不够(充值)、姿势不对(换母版)、产物格式不符(改取件逻辑)、任务超时(可能已计费)。
+# 全塞进 RuntimeError 就等于把这四种病压成一个症状,而这正是本仓吃过亏的地方。
+
+
+class ModelRejectCode:
+ """入口预检的拒绝原因。取值是稳定字符串,可进日志 / 进 API 响应。"""
+
+ UNREADABLE = "unreadable" # 解不开的 bytes / 不是 GLB 也不是 FBX
+ TOO_LARGE = "too_large" # 超过接口 60MB 上限
+ NOT_A_POSE = "not_a_pose" # 几何上明显不是 A/T-Pose(单侧判据,见 checks)
+ HAS_ACCESSORY = "has_accessory" # 检出人体以外的独立网格(弱信号,见 checks)
+
+
+class ModelRejected(Exception):
+ """送检模型不满足绑骨接口的硬约束 —— **在提交(花钱)之前**抛。
+
+ 为什么必须是入口异常而不是下游报错:这三条硬约束(≤60MB / A-T-Pose / 无武器配件)
+ **违反了不会报错,只会默默产出错结果**(实测:带剑的模型,剑被绑上权重、动画里
+ 到处乱甩;整单钱已经花完才发现)。与 ``ai_engine.master_check`` 对母版做入口预检
+ 是同一个动作,理由也是同一个。
+ """
+
+ def __init__(self, code: str, message: str) -> None:
+ super().__init__(f"[{code}] {message}")
+ self.code = code
+ self.message = message
+
+
+class ArtifactFormatError(RuntimeError):
+ """接口返回的产物格式与请求的不符 —— 拒绝当成功。
+
+ 2026-08-05 实测:请求 GLB 输入,返回的 ``ResultFile3Ds[0]`` 是 **FBX**,被按 ``.glb``
+ 存下,于是 Blender 报 "Bad glTF: json error: utf-8"、出帧台 waitForFunction 直接超时,
+ 排查方向被带到"出帧管线坏了"。管线里的修法是按后缀挑 + 挑不到就改文件名;
+ **bytes 进 bytes 出的接口没有文件名可改**,所以这里的修法是:按 Type 挑,挑不到就抛,
+ 并且落地前再用 magic bytes 复核一次 —— ``Type`` 是供应商的自述,magic 是事实。
+ """
+
+
+class ModelNotPublicError(RuntimeError):
+ """uploader 没给出 http(s) 公网 URL —— 绑骨服务器取不到这个模型。
+
+ 与 ``FirstFrameNotPublicError`` 同理:本地路径 / dataURI 在这一面必然产不出正确结果,
+ 宁可在提交前炸,也不要提交一个注定失败(但照样占用配额)的任务。
+ """
+
+
+class JobFailedError(RuntimeError):
+ """云端任务返回失败终态,或返回了认不出的状态。
+
+ 认不出的状态一律当失败,不 continue —— 那会一直转到超时,把"协议变了"伪装成"生成太慢"。
+ """
+
+
+class JobTimeoutError(RuntimeError):
+ """轮询预算耗尽仍未出结果(任务可能还在跑,**积分可能已经扣了**)。"""
+
+
+class InsufficientCreditsError(RuntimeError):
+ """账户积分不足(``ResourceInsufficient``)。
+
+ 单拎出来是因为它极易被误判成"接口坏了" —— 报错文本长得跟其他业务错误一样,
+ 但修法是充值,不是改代码。见过一次照着接口文档翻半天的。
+ """
+
+
+class RenderStageError(RuntimeError):
+ """本地出帧台失败(node / 浏览器 / 页面脚本)。零成本段,失败可以随便重试。"""
+
+
+# ── 值对象 ──────────────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class PresetMotion:
+ """绑骨接口自带的一个预设动作。
+
+ ``has_root_motion`` 恒为 ``False`` 且不是留白:48 个预设**全部是纯旋转的原地动画**,
+ 跑步、向前大跳实测根位移都是 0。所以 ``root_motion`` / ``move_speed`` 在这条来源上
+ **没有源数据**,只能由管线在图像空间量出来或人工设定 —— 别指望接口给。
+ """
+
+ name: str
+ motion_type: int
+ has_root_motion: bool = False
+
+
+@dataclass(frozen=True)
+class RiggedModel:
+ """绑骨产物。
+
+ 比"一坨 bytes"多出来的两样都是调用方必须知道、而 bytes 本身不带的:
+ ``fmt`` 是 **magic bytes 验过的真实格式**(不是接口自述的 Type),``motion`` 是这次
+ 烘进模型的预设动作(没请求动作时为 ``None``)。
+ """
+
+ data: bytes
+ fmt: str
+ motion: PresetMotion | None = None
+
+ def __post_init__(self) -> None:
+ if self.fmt not in MODEL_FORMATS:
+ raise ArtifactFormatError(f"fmt 只能是 {MODEL_FORMATS},收到 {self.fmt!r}")
+
+
+@dataclass(frozen=True)
+class RigInfo:
+ """出帧台从模型里读到的骨架事实。用于交付前核对,不参与渲染。
+
+ 已确立(不必每次重验):自动绑骨产出 **28 骨** · humanoid 命名 · **无 ``mixamorig:`` 前缀**。
+ 对不上说明拿到的不是我们这条链路的产物,该停下来看,而不是接着渲。
+ """
+
+ bones: int
+ skinned_meshes: int
+ vertices: int
+ root_bone: str | None
+ loader: str
+
+
+@dataclass(frozen=True)
+class SpriteSequence:
+ """一个朝向的一条序列帧。
+
+ ``direction`` 的取值域与前端导出模型的 ``ExportAction.sequences[].direction`` 一致
+ (e / ne / n / nw / w / sw / s / se),**不需要转换层**。
+ """
+
+ direction: str
+ camera_yaw: float
+ frames: Sequence[bytes] = field(repr=False)
+
+ def __len__(self) -> int:
+ return len(self.frames)
+
+
+@dataclass(frozen=True)
+class SpriteSheet:
+ """一次出帧的全部产物。
+
+ ``root_motion`` 是出帧台在**归一化单位**(1.0 = 角色总高)下从根骨位置轨抽出来的水平
+ 位移;走绑骨预设动作时它恒为 0(见 :class:`PresetMotion`)。留着这个字段是因为同一个
+ 出帧台也吃外部动画(Mixamo 等),那些是带位移的。
+ """
+
+ clip: str
+ duration_s: float
+ sample_times: Sequence[float]
+ sequences: Sequence[SpriteSequence]
+ rig: RigInfo
+ available_clips: Mapping[str, float]
+ root_motion: Mapping[str, object] | None = None
+
+ @property
+ def frame_count(self) -> int:
+ return sum(len(s) for s in self.sequences)
+
+
+# ── port ────────────────────────────────────────────────────────────────────
+
+
+@runtime_checkable
+class ModelUploader(Protocol):
+ """3D 模型 bytes → **公网可取的 URL**(给只吃 URL 的绑骨接口用)。
+
+ 与 :class:`FirstFrameUploader` **形状完全相同、契约不同**,故单列:
+
+ - 体量差一个量级(几十 MB 的模型 vs 一张首帧图),对象存储的分片 / 超时策略不一样;
+ - 有效期要求不同:URL 必须在**整个绑骨任务周期内**可取(排队 + 绑骨,实测 40–60s,
+ 但排队可能更久),不是发出去就完事;
+ - 返回的 URL 常常带签名(预签名 URL 里含 SecretId 与 signature),
+ **调用方不得把它写进日志 / 错误文本**。
+
+ 形状保持一致是有意的:产品仓里两个 port 可以共用同一批 uploader 实现。
+ """
+
+ def upload(self, model: bytes, content_type: str) -> str: ...
+
+
+# ── 三个 Protocol ───────────────────────────────────────────────────────────
+
+
+@runtime_checkable
+class Model3DProvider(Protocol):
+ """母版图 bytes → 3D 模型 bytes(图生 3D)。
+
+ **按次计费**。生成模式的积分是 provider 的构造参数,报价见实现的 ``quote()``;
+ 调用方要报价、要有人点头,不要在循环里跑。
+
+ ``want`` 是**保证**不是偏好:拿不到该格式就抛 :class:`ArtifactFormatError`,
+ 绝不返回另一种格式的 bytes(bytes 没有后缀可以说谎,也就没有地方能纠正)。
+ """
+
+ def image_to_3d(
+ self,
+ master: bytes,
+ *,
+ want: str = "GLB",
+ extra_views: Mapping[str, bytes] | None = None,
+ ) -> bytes: ...
+
+
+@runtime_checkable
+class AutoRigProvider(Protocol):
+ """3D 模型 bytes → 绑好骨的模型 bytes(+ 这次烘进去的预设动作)。
+
+ **按次计费**(10 积分/次,每角色一次性)。
+
+ 实现在构造时接一个 :class:`ModelUploader` —— 接口的 ``File3D.Url`` 只吃公网 URL,
+ 但那是 provider 自己的适配问题,不外泄到调用方(见模块 docstring)。
+
+ ``model`` 的格式由 magic bytes 嗅,不由调用方声明:嗅探是零成本且**不可能与事实矛盾**,
+ 而一个 ``fmt=`` 参数可以填错,填错的后果是提交一个注定产出错结果的任务。
+ """
+
+ @property
+ def preset_motions(self) -> Mapping[str, PresetMotion]:
+ """可请求的预设动作名 → 定义。全部 ``has_root_motion=False``。"""
+ ...
+
+ def rig(
+ self,
+ model: bytes,
+ *,
+ want: str = "GLB",
+ motion: str | int | None = None,
+ ) -> RiggedModel: ...
+
+
+@runtime_checkable
+class SpriteRenderProvider(Protocol):
+ """绑骨模型 bytes + 动作 + 朝向 → 各朝向序列帧 bytes。
+
+ **本地跑,零 API 成本** —— 这是三渲二相对逐帧 / 视频路线的主要杠杆:模型与动作都不变,
+ 只换相机方位角重渲一遍,各朝向天生一致(同一网格、同一骨骼、同一采样时刻)。
+ 逐帧 / 视频路线做同样的事是 N 倍生成费用,且各朝向之间没有一致性保证。
+
+ ``directions`` 只接受 4 或 8(8 向是 4 向的超集)。``material`` 必须是出帧台**真正认识**
+ 的取值,实现会校验 —— 详见 :mod:`.sprite` 里 ``MATERIALS`` 的注释。
+ """
+
+ def render(
+ self,
+ rigged_model: bytes,
+ *,
+ clip: str | None = None,
+ directions: int = 4,
+ frames: int = 12,
+ size: tuple[int, int] = RENDER_SIZE,
+ material: str = "cel",
+ ) -> SpriteSheet: ...
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py b/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py
new file mode 100644
index 00000000..b8fbd457
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/sprite.py
@@ -0,0 +1,269 @@
+"""``SpriteRenderProvider`` 的本地实现:绑骨模型 bytes → 各朝向序列帧 bytes。
+
+**这一段零 API 成本**,失败可以随便重试 —— 也正因为如此,它是整条链路上唯一能反复实验的
+一段,前面两段每跑一次都扣积分。
+
+多朝向是三渲二相对逐帧 / 视频路线的主要杠杆:模型与动作都不变,**只换相机方位角重渲一遍**,
+各朝向天生一致(同一网格、同一骨骼、同一采样时刻)。逐帧 / 视频路线做同样的事是 N 倍生成
+费用,而且各朝向之间没有一致性保证。
+
+实现形态:临时目录当 docroot(模型 + 出帧台页面 + 指向 three 的软链) → 起一个本地
+HTTP 服务 → node + playwright 驱动出帧台 → 读回 PNG bytes。
+**bytes 进 bytes 出**:落盘只是本实现内部的事,不外泄给调用方(与 uploader 那条约定同源 ——
+"该 provider 自己的适配问题")。
+"""
+from __future__ import annotations
+
+import functools
+import http.server
+import json
+import os
+import pathlib
+import shutil
+import socket
+import subprocess
+import tempfile
+import threading
+from collections.abc import Mapping
+
+from .checks import sniff_format
+from .interfaces import (
+ RENDER_SIZE,
+ RenderStageError,
+ RigInfo,
+ SpriteSequence,
+ SpriteSheet,
+)
+
+__all__ = ["LocalSpriteRenderProvider", "DIRECTIONS_4", "DIRECTIONS_8", "MATERIALS"]
+
+STAGE_DIR = pathlib.Path(__file__).resolve().parent / "stage"
+
+# 朝向名 → 相机方位角(度)。0° = 角色朝屏幕右(对齐 faces="right")。逆时针每 45° 一个,
+# 八向是四向的超集。键名与前端导出模型的 ``ExportAction.sequences[].direction`` 一致,
+# **不需要转换层**。
+DIRECTIONS_8 = {"e": 0, "ne": 45, "n": 90, "nw": 135, "w": 180, "sw": 225, "s": 270, "se": 315}
+DIRECTIONS_4 = {"e": 0, "n": 90, "w": 180, "s": 270}
+
+# 出帧台**真正认识**的材质取值,每个对应一个不同的渲染分支。
+#
+# 这张表存在的唯一理由是一个仪器陷阱:管线那份出帧台的材质分支只认三种取值,其余(包括
+# ``cel`` / ``studio``)静默落到同一个分支 —— 于是"拿两种材质做对照"实际上根本没换材质,
+# 得出的结论不作数。本 provider 因此**在边界上校验**,认不出的取值当场抛;出帧台内部也
+# 独立抛一次(双保险,免得有人绕过 provider 直接开页面)。
+MATERIALS = ("cel", "lit", "clay", "toon", "orig")
+
+_EXT = {"GLB": "glb", "FBX": "fbx"}
+
+
+def _free_port() -> int:
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return int(s.getsockname()[1])
+
+
+def _is_dir(p: pathlib.Path) -> bool:
+ """``p.is_dir()``,但把"问不出来"当成 False。
+
+ 向上搜 node_modules 会一路走到 ``/``,而根下有些合成入口对 ``stat`` 直接报错而不是
+ 返回"不存在":macOS 上 ``/.resolve/node_modules/three`` 抛 ``OSError(EINVAL)``,
+ 于是整条发现逻辑连同**所有**依赖它的用例一起崩(本机实测 6 个用例红,与被测代码无关)。
+ 找不到 three.js 是一种正常结果(调用方另有报错路径),搜索途中问不出来更是,
+ 两者都不该表现成崩溃。
+ """
+ try:
+ return p.is_dir()
+ except OSError:
+ return False
+
+
+def _find_dir(candidates: list[pathlib.Path]) -> pathlib.Path | None:
+ return next((c for c in candidates if _is_dir(c)), None)
+
+
+def _discover_three(levels: int = 6) -> pathlib.Path | None:
+ """找一份 three.js。显式参数 > ``WINDUP_THREE_DIR`` > 从 cwd / 包目录向上搜 node_modules。
+
+ 向上每一层还会看**该层的直接子目录**:这条线的出帧资产历史上住在某个子目录的
+ node_modules 里,不写死具体名字,只按 ``*/node_modules/three`` 这个通用形状找。
+ 搜索深度封顶(``levels``),免得在深路径上把整棵树 iterdir 一遍。
+
+ 产品仓里 three 是普通 npm 依赖,这个函数第一条就命中,后面几条都用不上。
+ """
+ env = os.environ.get("WINDUP_THREE_DIR")
+ if env:
+ return pathlib.Path(env)
+ roots: list[pathlib.Path] = []
+ for start in (pathlib.Path.cwd(), STAGE_DIR.parent):
+ roots += [start, *list(start.parents)[:levels]]
+ for root in roots:
+ direct = root / "node_modules" / "three"
+ if _is_dir(direct):
+ return direct
+ try:
+ children = sorted(c for c in root.iterdir() if c.is_dir())
+ except OSError:
+ continue
+ nested = _find_dir([c / "node_modules" / "three" for c in children])
+ if nested:
+ return nested
+ return None
+
+
+def _discover_playwright() -> str | None:
+ """找 playwright 入口。装在项目里时让 node 自己解析(返回 None)即可。"""
+ env = os.environ.get("PLAYWRIGHT_MODULE")
+ if env:
+ return env
+ globals_ = [pathlib.Path.home() / ".npm-global/lib/node_modules/playwright",
+ pathlib.Path("/usr/local/lib/node_modules/playwright"),
+ pathlib.Path("/opt/homebrew/lib/node_modules/playwright")]
+ found = _find_dir(globals_)
+ return str(found / "index.mjs") if found else None
+
+
+class _QuietHandler(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *args) -> None: # 出帧一次上百个请求,日志没有信息量
+ pass
+
+
+class LocalSpriteRenderProvider:
+ """本地出帧 provider。
+
+ ``three_dir`` / ``playwright_module`` 留成构造参数是为了搬仓:产品仓里它们是普通的
+ npm 依赖,一行都不用传;在本仓靠 :func:`_discover_three` / :func:`_discover_playwright`
+ 自动找。
+ """
+
+ def __init__(
+ self,
+ *,
+ three_dir: str | pathlib.Path | None = None,
+ playwright_module: str | None = None,
+ node: str = "node",
+ min_coverage: float = 0.005,
+ timeout_s: int = 900,
+ ) -> None:
+ self._three = pathlib.Path(three_dir) if three_dir else _discover_three()
+ self._pw = playwright_module or _discover_playwright()
+ self._node = node
+ self._min_coverage = min_coverage
+ self._timeout = timeout_s
+
+ # ── Protocol ────────────────────────────────────────────────────────────
+
+ def render(
+ self,
+ rigged_model: bytes,
+ *,
+ clip: str | None = None,
+ directions: int = 4,
+ frames: int = 12,
+ size: tuple[int, int] = RENDER_SIZE,
+ material: str = "cel",
+ ) -> SpriteSheet:
+ if directions not in (4, 8):
+ raise ValueError(f"朝向数只支持 4 或 8(八向是四向的超集),收到 {directions}")
+ if material not in MATERIALS:
+ raise ValueError(
+ f"未知材质 {material!r};出帧台只认 {MATERIALS}。"
+ "别指望它会兜底 —— 静默落到同一分支正是这条线踩过的仪器陷阱。")
+ if frames < 1:
+ raise ValueError(f"帧数至少 1,收到 {frames}")
+ fmt = sniff_format(rigged_model) # 嗅探,不由调用方声明
+ if self._three is None:
+ raise RenderStageError(
+ "找不到 three.js。装一份(npm i three)或用 WINDUP_THREE_DIR / "
+ "LocalSpriteRenderProvider(three_dir=...) 指过去。")
+
+ table = DIRECTIONS_8 if directions == 8 else DIRECTIONS_4
+ with tempfile.TemporaryDirectory(prefix="windup_bake_") as tmp:
+ root = pathlib.Path(tmp)
+ docroot = root / "www"
+ docroot.mkdir()
+ model_name = f"model.{_EXT[fmt]}"
+ (docroot / model_name).write_bytes(rigged_model)
+ shutil.copy2(STAGE_DIR / "bake_stage.html", docroot / "bake_stage.html")
+ # 软链而不是拷贝:three 整包上百 MB,每次出帧拷一遍纯属浪费。
+ (docroot / "three").symlink_to(self._three.resolve(), target_is_directory=True)
+
+ out = root / "out"
+ out.mkdir()
+ port = _free_port()
+ server = http.server.ThreadingHTTPServer(
+ ("127.0.0.1", port),
+ functools.partial(_QuietHandler, directory=str(docroot)))
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ meta = self._drive(port, model_name, out, table, clip, frames, size, material)
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+ return self._collect(meta, out, table)
+
+ # ── 内部 ────────────────────────────────────────────────────────────────
+
+ def _drive(self, port: int, model_name: str, out: pathlib.Path,
+ table: Mapping[str, int], clip: str | None, frames: int,
+ size: tuple[int, int], material: str) -> Mapping:
+ w, h = size
+ url = (f"http://127.0.0.1:{port}/bake_stage.html?model=/{model_name}"
+ f"&mat={material}&w={w}&h={h}")
+ env = {
+ **os.environ,
+ "STAGE_URL": url, "OUT": str(out),
+ "DIRS": json.dumps([[k, v] for k, v in table.items()]),
+ "CLIP": clip or "", "N": str(frames),
+ "MIN_COVERAGE": str(self._min_coverage),
+ }
+ if self._pw:
+ env["PLAYWRIGHT_MODULE"] = self._pw
+ try:
+ proc = subprocess.run(
+ [self._node, str(STAGE_DIR / "bake_driver.mjs")],
+ env=env, capture_output=True, text=True, timeout=self._timeout)
+ except FileNotFoundError as exc:
+ raise RenderStageError(f"起不来 node({self._node}):{exc}") from exc
+ except subprocess.TimeoutExpired as exc:
+ raise RenderStageError(f"出帧超时({self._timeout}s)") from exc
+ if proc.returncode == 2:
+ # 空帧自检。**不让全透明帧冒充成功** —— 这一步在管线里是拿教训换来的:
+ # 三帧 alpha 全 0,外层照样打印"N 帧 时长…",没有任何告警。
+ raise RenderStageError(f"出帧台空帧自检不通过:\n{proc.stderr.strip()}")
+ if proc.returncode != 0:
+ raise RenderStageError(
+ f"出帧失败(退出码 {proc.returncode}):\n{proc.stderr.strip() or proc.stdout.strip()}")
+
+ meta_path = out / "bake_meta.json"
+ if not meta_path.exists():
+ raise RenderStageError(f"出帧台没写 bake_meta.json;stdout:\n{proc.stdout.strip()}")
+ return json.loads(meta_path.read_text())
+
+ def _collect(self, meta: Mapping, out: pathlib.Path,
+ table: Mapping[str, int]) -> SpriteSheet:
+ sequences = []
+ for name, yaw in table.items():
+ frame_dir = out / name
+ files = sorted(frame_dir.glob("f*.png"))
+ if not files:
+ raise RenderStageError(f"朝向 {name} 一帧都没出({frame_dir})")
+ sequences.append(SpriteSequence(direction=name, camera_yaw=float(yaw),
+ frames=tuple(f.read_bytes() for f in files)))
+ rig = meta.get("rig") or {}
+ return SpriteSheet(
+ clip=str(meta["clip"]),
+ duration_s=float(meta.get("duration") or 0.0),
+ sample_times=tuple(meta.get("sample_times") or ()),
+ sequences=tuple(sequences),
+ rig=RigInfo(
+ bones=int(rig.get("bones", 0)),
+ skinned_meshes=int(rig.get("skinned", 0)),
+ vertices=int(rig.get("verts", 0)),
+ root_bone=rig.get("rootBone"),
+ loader=str(rig.get("loader", "?")),
+ ),
+ available_clips=dict(meta.get("clips") or {}),
+ root_motion=meta.get("root_motion"),
+ )
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs b/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs
new file mode 100644
index 00000000..da2a6d3a
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_driver.mjs
@@ -0,0 +1,97 @@
+/**
+ * 出帧台的无头驱动。由 LocalSpriteRenderProvider 起,通过环境变量传参,产物落盘后由 Python 读回。
+ *
+ * 为什么是"落盘再读回"而不是把 bytes 直接回传:一次 8 向 × 12 帧 = 96 张 PNG,
+ * 走 stdout 传 base64 既慢又会把 JSON 撑爆;而临时目录本来就是 provider 建的、用完就删。
+ *
+ * 环境变量:
+ * STAGE_URL 出帧台页面 URL(已带 model / mat / w / h 查询串)
+ * OUT 产物目录
+ * DIRS JSON:[["e",0],["n",90],...] 朝向名 → 相机方位角(度)
+ * CLIP 片段名;缺省用第一个
+ * N 每个朝向的帧数
+ * MIN_COVERAGE 空帧自检阈值(非透明像素占比),默认 0.005
+ * PLAYWRIGHT_MODULE playwright 的入口路径(本机全局装的时候要指);缺省按包名解析
+ *
+ * 退出码:0 正常;2 空帧自检不通过(**不让全透明帧冒充成功**);1 其它错误。
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const {
+ STAGE_URL, OUT, DIRS, CLIP, N = '12', MIN_COVERAGE = '0.005', PLAYWRIGHT_MODULE,
+} = process.env;
+
+const pw = await import(PLAYWRIGHT_MODULE || 'playwright');
+const dirs = JSON.parse(DIRS);
+const n = +N;
+const minCov = +MIN_COVERAGE;
+
+// 用 playwright 自带的 chromium,不指定 channel:'chrome'。品牌版 Chrome 要另外装,
+// 容器镜像里没有,于是渲帧段在任何容器里都起不来(实测报 "Chromium distribution
+// 'chrome' is not found")。也不做"先试 chrome 再退回"的链 —— 出帧结果依赖具体
+// 浏览器,静默换一个等于同一份模型在不同机器上出不同的帧。
+const browser = await pw.chromium.launch();
+const page = await browser.newPage({ viewport: { width: 400, height: 300 } });
+const errs = [];
+page.on('pageerror', e => errs.push(String(e.message)));
+page.on('console', m => { if (m.type() === 'error' && !/favicon/i.test(m.text())) errs.push(m.text()); });
+
+try {
+ await page.goto(STAGE_URL, { waitUntil: 'domcontentloaded' });
+ // 模型可能有几十 MB,加载 + 解析要时间。__ready 由页面末尾置位;它没来就是页面炸了,
+ // 把捕获到的页面错误一起报出来 —— 否则只剩一句 timeout,看不出是模型坏了还是脚本坏了。
+ await page.waitForFunction('window.__ready===true', { timeout: 180000 })
+ .catch(e => { throw new Error(`出帧台没就绪:${e.message}\n页面错误:${[...new Set(errs)].join(' | ') || '(无)'}`); });
+
+ const clips = await page.evaluate(() => window.__clips());
+ const names = Object.keys(clips);
+ if (!names.length) throw new Error('模型里没有任何动画片段 —— 绑骨时没带 MotionType?');
+ const clip = CLIP && CLIP.length ? CLIP : names[0];
+ if (!clips[clip]) throw new Error(`模型里没有片段 ${JSON.stringify(clip)};有的是 ${JSON.stringify(names)}`);
+
+ const rig = await page.evaluate(() => window.__rigInfo());
+ const rootMotion = await page.evaluate(() => window.__rootMotion());
+
+ const meta = { clip, duration: clips[clip], clips, rig, root_motion: rootMotion[clip] ?? null,
+ frames: n, directions: {}, sample_times: [], coverage: {} };
+ const empties = [];
+
+ for (const [name, yaw] of dirs) {
+ await page.evaluate(y => window.__setCamYaw(y), yaw);
+ const outDir = path.join(OUT, name);
+ fs.mkdirSync(outDir, { recursive: true });
+ const times = [], covs = [];
+ for (let i = 0; i < n; i++) {
+ const info = await page.evaluate(([c, i, n]) => window.__setup(c, i, n), [clip, i, n]);
+ if (!info) throw new Error(`__setup 拿不到片段 ${clip}`);
+ const cov = await page.evaluate(() => window.__coverage());
+ const dataUrl = await page.evaluate(() => window.__grab());
+ // 形状守卫:空画布时 toDataURL 会给出 "data:," 这类没有 base64 段的串,
+ // 切出来是空字符串,会写成一张 0 字节 PNG —— 而 _collect 只按文件存在与否
+ // 收帧,于是坏帧一路当成正常产物交付。覆盖率自检是第二道,这里先炸得更早更清楚。
+ const b64 = typeof dataUrl === 'string' ? dataUrl.split(',')[1] : '';
+ if (!b64) throw new Error(`${name}/f${i} 截图为空(dataURL=${String(dataUrl).slice(0, 32)})`);
+ fs.writeFileSync(path.join(outDir, `f${String(i).padStart(2, '0')}.png`),
+ Buffer.from(b64, 'base64'));
+ times.push(info.t); covs.push(+cov.toFixed(5));
+ if (cov < minCov) empties.push(`${name}/f${String(i).padStart(2, '0')} 覆盖率 ${cov.toFixed(5)}`);
+ }
+ meta.directions[name] = { yaw, dir: outDir, frames: n };
+ meta.coverage[name] = covs;
+ if (!meta.sample_times.length) meta.sample_times = times;
+ }
+
+ meta.page_errors = [...new Set(errs)];
+ fs.writeFileSync(path.join(OUT, 'bake_meta.json'), JSON.stringify(meta, null, 2));
+
+ if (empties.length) {
+ // 空帧自检:台子可以静默产出全透明帧(角色出画 / 片段选错),外面照样以为成功了。
+ console.error(`空帧自检不通过,${empties.length} 帧几乎全透明:\n ` + empties.join('\n '));
+ process.exit(2);
+ }
+ console.log(JSON.stringify({ ok: true, clip, dirs: dirs.length, frames: n,
+ page_errors: meta.page_errors.length }));
+} finally {
+ await browser.close();
+}
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_stage.html b/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_stage.html
new file mode 100644
index 00000000..df6484d3
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/stage/bake_stage.html
@@ -0,0 +1,272 @@
+
+
+
+
+绑骨模型 → 多朝向序列帧(出帧台)
+
+
+
+
+
+
+
diff --git a/backend/packages/framework/src/windup_framework/providers/render3d/tencent.py b/backend/packages/framework/src/windup_framework/providers/render3d/tencent.py
new file mode 100644
index 00000000..32dc217c
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/render3d/tencent.py
@@ -0,0 +1,492 @@
+"""三个 Protocol 里两个**按次计费**那两个的腾讯云混元实现(+ 一个 COS uploader)。
+
+ TencentModel3DProvider SubmitHunyuanTo3DProJob / QueryHunyuanTo3DProJob
+ TencentAutoRigProvider SubmitAutoRiggingJob / DescribeAutoRiggingJob
+ TencentCosModelUploader ModelUploader —— 模型 bytes → COS 预签名 URL
+
+同域名同版本(``ai3d.tencentcloudapi.com`` · ``2025-05-13``),都是异步提交 + 轮询。
+
+**计费(每角色一次性)**:建模按生成模式取积分(Normal 20 / LowPoly 25 / Geometry 15 /
+Sketch 25;PBR、多视图各 +10),绑骨固定 10 积分,后付费 0.12 元/积分。套预设动作与本地
+渲帧 **0 元**。积分不足报 ``ResourceInsufficient`` —— 那是充值问题,不是接口坏了,
+本模块把它翻成 :class:`InsufficientCreditsError` 单独一类,免得又照着文档排查半天。
+
+**花钱要有人点头**:两个 provider 都有 ``allow_spend`` 构造开关,默认 ``False``。
+默认档下调用会抛 :class:`SpendNotAuthorizedError`,并把这一次的报价写进异常文本 ——
+提交路径因此不可能被"顺手跑一下"触发。开关放在构造而不是方法参数上,是为了让
+Protocol 的方法签名保持干净(调用点不需要知道这条链路要花钱)。
+"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import logging
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from collections.abc import Mapping
+
+from ._tc3 import TencentApiError, TencentCredentials, call, cos_request, cos_sign, redact
+from .checks import check_model, sniff_format
+from .interfaces import (
+ MODEL_FORMATS,
+ ArtifactFormatError,
+ InsufficientCreditsError,
+ JobFailedError,
+ JobTimeoutError,
+ ModelNotPublicError,
+ ModelUploader,
+ PresetMotion,
+ RiggedModel,
+)
+
+__all__ = [
+ "TencentModel3DProvider", "TencentAutoRigProvider", "TencentCosModelUploader",
+ "SpendNotAuthorizedError", "CREDITS", "CREDIT_PRICE_CNY", "PRESET_MOTIONS",
+ "RIG_CREDITS", "MAX_IMAGE_BYTES", "VIEW_TYPES",
+]
+
+logger = logging.getLogger(__name__)
+
+SERVICE = "ai3d"
+VERSION = "2025-05-13"
+
+CREDITS = {"Normal": 20, "LowPoly": 25, "Geometry": 15, "Sketch": 25}
+RIG_CREDITS = 10
+CREDIT_PRICE_CNY = 0.12 # 后付费单价;预付费 0.09–0.1
+MAX_IMAGE_BYTES = 6 * 10**6 # ImageBase64 上限
+VIEW_TYPES = ("back", "left", "right") # 正面走主参数,不在这里
+
+# 轮询中允许继续等的状态。**认不出的状态一律当失败**并把 JobId 写进异常 ——
+# 一直 continue 会转到超时,把"协议变了"伪装成"生成太慢";而带着 JobId 抛出去,
+# 调用方随时能再查一次(任务在云上还在跑,不会因为我们抛错就没了)。
+_RUNNING = {"WAIT", "RUN", "RUNNING", "INIT", "PROCESSING", "QUEUING"}
+
+PRESET_MOTIONS: Mapping[str, PresetMotion] = {
+ # 只登记横版会用到、且**实测过编号**的那些。1–48 全集见接口文档;没实测过的不编名字,
+ # 免得一个猜出来的名字被当成事实用下去。要用别的直接传 1–48 的整数。
+ "idle": PresetMotion("idle", 26),
+ "idle_2": PresetMotion("idle_2", 27),
+ "walk": PresetMotion("walk", 23),
+ "walk_2": PresetMotion("walk_2", 24),
+ "jog": PresetMotion("jog", 32),
+ "run": PresetMotion("run", 34),
+ "jump": PresetMotion("jump", 38),
+ "jump_forward": PresetMotion("jump_forward", 40),
+ "kick": PresetMotion("kick", 18),
+ "thrust": PresetMotion("thrust", 16),
+}
+MOTION_TYPE_RANGE = range(1, 49)
+
+_MAGIC = {"GLB": b"glTF", "FBX": b"Kaydara FBX Binary"}
+
+
+class SpendNotAuthorizedError(RuntimeError):
+ """provider 构造时没有 ``allow_spend=True``,拒绝提交计费任务。异常文本里带报价。"""
+
+
+# ── 共用的小工具 ────────────────────────────────────────────────────────────
+
+
+def _raise_for_error(response: Mapping) -> Mapping:
+ """把 ``Response.Error`` 翻成分得开的异常。"""
+ err = response.get("Error")
+ if not err:
+ return response
+ code = str(err.get("Code", ""))
+ msg = str(err.get("Message", err))
+ if "ResourceInsufficient" in code:
+ raise InsufficientCreditsError(
+ f"积分不足({code}: {msg})。这是充值问题,不是接口坏了 —— "
+ "别照着接口文档排查参数。")
+ raise TencentApiError(code or "UnknownError", msg)
+
+
+def _check_image_size(data: bytes, which: str) -> None:
+ if len(data) > MAX_IMAGE_BYTES:
+ raise ValueError(
+ f"{which} 图 {len(data) / 10**6:.1f}MB,超过 ImageBase64 上限 "
+ f"{MAX_IMAGE_BYTES / 10**6:.0f}MB")
+
+
+def _pick_artifact(files: list, want: str, *, strict: bool = True,
+ job_id: str = "") -> tuple[Mapping, str]:
+ """按格式挑产物,返回 ``(产物, 它真实的格式)``。**绝不退回 files[0]**。
+
+ ``strict=True``:只认 ``want``。给返回裸 bytes 的调用方用 —— 那边没有字段能说明
+ 真实格式,拿到 FBX 当 GLB 用会让下游报 Bad glTF,症状伪装成"出帧管线坏了"。
+
+ ``strict=False``:拿不到首选就退到另一个可用格式,真实格式随返回值带出去。给
+ ``RiggedModel`` 那种能如实标注 fmt 的调用方用 —— 那里硬要求只会让已经扣过费的
+ 产物取不回来,而两种格式下游都吃。
+ """
+ def _fmt(f) -> str:
+ return str(f.get("Type", "")).upper() if isinstance(f, Mapping) else ""
+
+ order = (want,) if strict else (want, *(f for f in MODEL_FORMATS if f != want))
+ for fmt in order:
+ hit = [f for f in files if _fmt(f) == fmt]
+ if hit:
+ return hit[0], fmt
+ got = [_fmt(f) or "?" for f in files]
+ raise ArtifactFormatError(
+ f"接口没有返回{'' if strict else '任何可用'}{want if strict else '格式'}产物,"
+ f"这次返回的是 {got}(可选 {MODEL_FORMATS})。"
+ + (f" JobId={job_id} —— 费用已产生,用它重取,别重新提交。" if job_id else ""))
+
+
+def _verify_magic(data: bytes, want: str) -> None:
+ """落地前用 magic bytes 复核。``Type`` 是供应商的自述,magic 才是事实。"""
+ magic = _MAGIC[want]
+ if data[:len(magic)] != magic:
+ raise ArtifactFormatError(
+ f"产物自称 {want},但头 {len(magic)} 字节是 {data[:len(magic)]!r},"
+ f"不是 {magic!r} —— 别把它当 {want} 存下去。")
+
+
+def _download(url: str, timeout: int = 600, tries: int = 3) -> bytes:
+ """下载已生成好的产物,带重试 + 长度校验。
+
+ 重试是安全的:这是对成品 URL 的 GET,幂等且**不再扣积分** —— 重试的代价是一次重下,
+ 不重试的代价是一次重新生成(积分已经花了)。
+ 长度校验是因为截断不一定抛异常:服务端提前关流时可能直接返回短 bytes,
+ 坏模型会一路流到出帧环节才暴露,在那儿看起来像"绑骨坏了"。
+ 异常文本一律过 :func:`redact` —— 这些 URL 可能带签名。
+ """
+ # 产物 URL 来自接口响应,不是我们拼的。限定 https 是因为 urllib 会照单全收
+ # file:// 与内网地址,并且默认跟随跳转 —— 上游若被污染,这里会把任意本地文件
+ # 或元数据端点的内容当作"模型 bytes"返回,而下游只做 magic 校验。
+ if urllib.parse.urlparse(url).scheme != "https":
+ raise ValueError(f"产物 URL 协议不是 https,拒绝下载:{redact(url)}")
+ last: Exception | None = None
+ for attempt in range(tries):
+ try:
+ with urllib.request.urlopen(url, timeout=timeout) as r:
+ body = r.read()
+ expected = r.headers.get("Content-Length")
+ if expected and len(body) != int(expected):
+ raise OSError(f"下载不完整: {len(body)}/{expected} 字节")
+ return body
+ except Exception as exc: # noqa: BLE001 —— 网络层什么都可能
+ last = exc
+ if attempt < tries - 1:
+ time.sleep(2**attempt)
+ raise JobFailedError(redact(f"产物下载失败(已重试 {tries} 次): {last}")) from last
+
+
+# ── uploader ────────────────────────────────────────────────────────────────
+
+
+class TencentCosModelUploader(ModelUploader):
+ """模型 bytes → COS 预签名 URL(私有桶 + 限时读)。
+
+ key 用 **内容哈希**:同一份模型重传不会在桶里堆副本,重试也天然幂等。
+ 桶名 ``windup-rig-``;AppId 由 ``cam:GetUserAppId`` 运行时取一次,不写死。
+
+ ``expire`` 默认 2 小时:URL 必须在**整个绑骨任务周期内**可取(排队 + 绑骨),
+ 实测单次 40–60 秒,但排队时间不受我们控制,留足余量。
+ """
+
+ _EXT = {"model/gltf-binary": "glb", "application/octet-stream": "bin",
+ "application/x-fbx": "fbx"}
+
+ def __init__(self, creds: TencentCredentials | None = None, *,
+ bucket_prefix: str = "windup-rig", expire: int = 7200) -> None:
+ self._creds = creds or TencentCredentials.resolve()
+ self._prefix = bucket_prefix
+ self._expire = expire
+ self._appid: str | None = None
+
+ def appid(self) -> str:
+ if self._appid is None:
+ r = _raise_for_error(call("GetUserAppId", {}, service="cam",
+ version="2019-01-16", creds=self._creds,
+ idempotent=True))
+ if "AppId" not in r:
+ raise JobFailedError(redact(f"取 AppId 失败: {r}"))
+ self._appid = str(r["AppId"])
+ return self._appid
+
+ def bucket(self) -> str:
+ return f"{self._prefix}-{self.appid()}"
+
+ def host(self) -> str:
+ return f"{self.bucket()}.cos.{self._creds.region}.myqcloud.com"
+
+ def ensure_bucket(self) -> None:
+ code, body = cos_request(self._creds, "PUT", "/", self.host())
+ if code not in (200, 409): # 409 = 已存在且属于你
+ raise JobFailedError(redact(f"建桶失败 {code}: {body}"))
+
+ def upload(self, model: bytes, content_type: str) -> str:
+ ext = self._EXT.get(content_type, "bin")
+ key = f"{hashlib.sha256(model).hexdigest()[:32]}.{ext}"
+ host = self.host()
+ self.ensure_bucket()
+ code, body = cos_request(self._creds, "PUT", f"/{key}", host, data=model)
+ if code != 200:
+ raise JobFailedError(redact(f"上传失败 {code}: {body}"))
+ return f"https://{host}/{key}?{cos_sign(self._creds, 'GET', f'/{key}', host, self._expire)}"
+
+
+# ── 图生 3D ─────────────────────────────────────────────────────────────────
+
+
+class TencentModel3DProvider:
+ """母版图 bytes → 3D 模型 bytes(混元生 3D 专业版)。
+
+ **输入端要点**:``ImageBase64`` ≤6MB;``Prompt`` 与图片输入互斥(本 provider 只走图);
+ 背景会被一起建模 —— 送检前把背景压白/透明。
+ **输出端要点**:``FaceCount`` 默认 500000,出来的 GLB 常远超绑骨的 60MB 上限。
+ 本实现默认要 150000 面,少一趟"减面再来"的往返。
+ """
+
+ def __init__(
+ self,
+ creds: TencentCredentials | None = None,
+ *,
+ generate_type: str = "Normal",
+ face_count: int = 150000,
+ enable_pbr: bool = False,
+ allow_spend: bool = False,
+ request_result_format: bool = False,
+ poll_interval: float = 20.0,
+ max_min: int = 20,
+ ) -> None:
+ if generate_type not in CREDITS:
+ # 构造即校验:未知生成模式在**花钱之前**就炸,和 FalQueueVideoProvider 一个道理。
+ raise ValueError(f"未知 GenerateType={generate_type!r},可选 {sorted(CREDITS)}")
+ self._creds = creds or TencentCredentials.resolve()
+ self._type = generate_type
+ self._faces = face_count
+ self._pbr = enable_pbr
+ self._allow_spend = allow_spend
+ self._ask_format = request_result_format
+ self._poll = poll_interval
+ self._max_min = max_min
+
+ def quote(self, n_views: int = 1) -> tuple[int, float]:
+ """返回 (积分, 预估元)。PBR、多视图各 +10 积分。纯计算,可在提交前随便调。"""
+ credits = CREDITS[self._type] + (10 if self._pbr else 0) + (10 if n_views > 1 else 0)
+ return credits, round(credits * CREDIT_PRICE_CNY, 2)
+
+ def build_params(self, master: bytes,
+ extra_views: Mapping[str, bytes] | None = None) -> dict:
+ """组装请求参数(**不发请求**)。先看一眼再提交,别盲交。
+
+ ``extra_views``:``{"back": bytes, "right": bytes}``,正面走 ``master``。
+ 多视图重建质量明显优于单图,代价 +10 积分,**硬前提是各视图必须是同一个角色、
+ 同一姿势、同一尺度** —— 侧/背视要以正面母版做参考图 i2i 出,各自文生等于送了三个人进去。
+ """
+ # 每张图都要查,不只是 master:超限的侧/背视同样会让整单被拒,而那时钱已经花了。
+ _check_image_size(master, "master")
+ for view, raw in (extra_views or {}).items():
+ _check_image_size(raw, view)
+ params: dict = {
+ "ImageBase64": base64.b64encode(master).decode(),
+ "GenerateType": self._type,
+ "FaceCount": self._faces,
+ }
+ if extra_views:
+ bad = sorted(set(extra_views) - set(VIEW_TYPES))
+ if bad:
+ raise ValueError(f"ViewType 只能取 {VIEW_TYPES},收到 {bad}(正面走主参数)")
+ params["MultiViewImages"] = [
+ {"ViewType": v, "ViewImageBase64": base64.b64encode(extra_views[v]).decode()}
+ for v in extra_views
+ ]
+ if self._pbr:
+ params["EnablePBR"] = True
+ return params
+
+ def image_to_3d(self, master: bytes, *, want: str = "GLB",
+ extra_views: Mapping[str, bytes] | None = None) -> bytes:
+ if want not in MODEL_FORMATS:
+ raise ArtifactFormatError(f"want 只能是 {MODEL_FORMATS},收到 {want!r}")
+ params = self.build_params(master, extra_views)
+ credits, cny = self.quote(1 + len(extra_views or {}))
+ if not self._allow_spend:
+ raise SpendNotAuthorizedError(
+ f"图生 3D 会消耗 {credits} 积分(后付费约 ¥{cny})。"
+ "确认要花这笔钱后,用 TencentModel3DProvider(..., allow_spend=True) 构造。")
+
+ if self._ask_format:
+ # **默认不发这个参数**。理由:管线里 `result_format` 一直是可选且从未默认用过,
+ # 我也没有实测证据说 SubmitHunyuanTo3DProJob 收 ``ResultFormat``;而"想要的格式"
+ # 已经由取件端保证(按 Type 挑 + magic 复核,那条路是实测过的)。
+ # 发一个没验证过的参数只增加被网关拒的风险,换不到任何保证 —— 所以留成开关,
+ # 等谁真的实测过再默认打开。
+ params["ResultFormat"] = want
+ job = self._submit(params)
+ files = self._wait(job)
+ picked, _ = _pick_artifact(files, want, job_id=job)
+ data = _download(str(picked["Url"]))
+ _verify_magic(data, want)
+ return data
+
+ def _submit(self, params: dict) -> str:
+ r = _raise_for_error(call("SubmitHunyuanTo3DProJob", params, service=SERVICE,
+ version=VERSION, creds=self._creds))
+ if "JobId" not in r:
+ raise JobFailedError(redact(f"提交图生 3D 没拿到 JobId: {r}"))
+ return str(r["JobId"])
+
+ def _wait(self, job_id: str) -> list:
+ for _ in range(max(1, int(self._max_min * 60 // self._poll))):
+ r = _raise_for_error(call("QueryHunyuanTo3DProJob", {"JobId": job_id},
+ service=SERVICE, version=VERSION, creds=self._creds,
+ idempotent=True))
+ status = str(r.get("Status") or "")
+ if status == "DONE":
+ files = r.get("ResultFile3Ds") or r.get("ResultFile3D") or []
+ files = files if isinstance(files, list) else [files]
+ if not files:
+ raise JobFailedError(f"图生 3D 完成但无产物(JobId={job_id})")
+ return files
+ if status == "FAIL":
+ raise JobFailedError(redact(
+ f"图生 3D 失败(JobId={job_id}): {r.get('ErrorMessage') or r}"))
+ if status.upper() not in _RUNNING:
+ raise JobFailedError(redact(
+ f"图生 3D 返回未知状态 {status!r}(JobId={job_id}): {r} —— "
+ "任务可能仍在跑,拿这个 JobId 再查一次。"))
+ time.sleep(self._poll)
+ raise JobTimeoutError(
+ f"图生 3D 轮询 {self._max_min} 分钟仍未出结果(JobId={job_id});积分可能已经扣了")
+
+
+# ── 自动绑骨 ────────────────────────────────────────────────────────────────
+
+
+class TencentAutoRigProvider:
+ """3D 模型 bytes → 绑骨模型 bytes(+ 这次烘进去的预设动作)。
+
+ ``uploader`` **必填且无默认值** —— 接口的 ``File3D.Url`` 只吃公网 URL,构造不出一个
+ "没有上传能力的绑骨 provider",免得跑到线上才发现模型送不出去。
+ (与 ``FalQueueVideoProvider`` 必须接 ``FirstFrameUploader`` 完全同构。)
+
+ 产出骨架(已确立,不必每次重验):**28 骨** · root/Hips/Spine/Neck/Head + 四肢 ·
+ **无 ``mixamorig:`` 前缀** · 无手指链 · Spine 单节。挂点按骨名寻址,去前缀后可直接
+ 复用既有握持参数,无需重标定。
+ """
+
+ def __init__(
+ self,
+ uploader: ModelUploader,
+ creds: TencentCredentials | None = None,
+ *,
+ allow_spend: bool = False,
+ precheck: bool = True,
+ poll_interval: float = 15.0,
+ max_min: int = 10,
+ ) -> None:
+ self._uploader = uploader
+ self._creds = creds or TencentCredentials.resolve()
+ self._allow_spend = allow_spend
+ self._precheck = precheck
+ self._poll = poll_interval
+ self._max_min = max_min
+
+ @property
+ def preset_motions(self) -> Mapping[str, PresetMotion]:
+ return PRESET_MOTIONS
+
+ def quote(self) -> tuple[int, float]:
+ return RIG_CREDITS, round(RIG_CREDITS * CREDIT_PRICE_CNY, 2)
+
+ def resolve_motion(self, motion: str | int | None) -> PresetMotion | None:
+ """动作名 / 编号 → :class:`PresetMotion`。名字不认识就抛,不猜编号。"""
+ if motion is None:
+ return None
+ if isinstance(motion, str):
+ try:
+ return PRESET_MOTIONS[motion]
+ except KeyError:
+ raise KeyError(
+ f"没登记的动作名 {motion!r};已登记 {sorted(PRESET_MOTIONS)},"
+ f"或直接传 {MOTION_TYPE_RANGE.start}–{MOTION_TYPE_RANGE.stop - 1} 的编号。"
+ ) from None
+ mt = int(motion)
+ if mt not in MOTION_TYPE_RANGE:
+ raise ValueError(
+ f"MotionType 只能是 {MOTION_TYPE_RANGE.start}–{MOTION_TYPE_RANGE.stop - 1},收到 {mt}")
+ known = next((p for p in PRESET_MOTIONS.values() if p.motion_type == mt), None)
+ return known or PresetMotion(f"motion_{mt}", mt)
+
+ def rig(self, model: bytes, *, want: str = "GLB",
+ motion: str | int | None = None) -> RiggedModel:
+ if want not in MODEL_FORMATS:
+ raise ArtifactFormatError(f"want 只能是 {MODEL_FORMATS},收到 {want!r}")
+ src_fmt = sniff_format(model) # 嗅探,不信调用方声明
+ preset = self.resolve_motion(motion) # 先把认不出的动作名炸掉
+ if self._precheck:
+ # 入口预检在**上传与提交之前**:三条硬约束违反了接口不报错,只默默出错结果。
+ check_model(model)
+
+ credits, cny = self.quote()
+ if not self._allow_spend:
+ raise SpendNotAuthorizedError(
+ f"绑骨会消耗 {credits} 积分(后付费约 ¥{cny})。"
+ "确认要花这笔钱后,用 TencentAutoRigProvider(..., allow_spend=True) 构造。")
+
+ url = self._upload(model, src_fmt)
+ job = self._submit(url, src_fmt, preset)
+ logger.info("绑骨已提交并计费 JobId=%s —— 后续任何失败都用它重取,别重新提交", job)
+ files = self._wait(job)
+ picked, got = _pick_artifact(files, want, strict=False, job_id=job)
+ data = _download(str(picked["Url"]))
+ _verify_magic(data, got)
+ return RiggedModel(data=data, fmt=got, motion=preset)
+
+ def fetch(self, job_id: str, *, want: str = "GLB") -> RiggedModel:
+ """取一个**已完成**任务的产物。零成本,不重新提交。
+
+ 存在的理由:提交之后的任何失败(格式、下载、进程被杀)都不该让已经扣过的费作废。
+ """
+ picked, got = _pick_artifact(self._wait(job_id), want, strict=False, job_id=job_id)
+ data = _download(str(picked["Url"]))
+ _verify_magic(data, got)
+ return RiggedModel(data=data, fmt=got, motion=None)
+
+ def _upload(self, model: bytes, fmt: str) -> str:
+ ct = "model/gltf-binary" if fmt == "GLB" else "application/x-fbx"
+ url = self._uploader.upload(model, ct)
+ if not isinstance(url, str) or not url.startswith(("http://", "https://")):
+ # 本地路径 / dataURI 在这一面必然产不出正确结果 —— 宁可在提交前炸。
+ # 注意这里**不回显 url 全文**:预签名 URL 里带 SecretId 与签名。
+ raise ModelNotPublicError(
+ f"uploader 必须返回 http(s) 公网 URL(绑骨服务器要能取到),"
+ f"收到 {redact(str(url))[:80]!r}")
+ return url
+
+ def _submit(self, model_url: str, fmt: str, preset: PresetMotion | None) -> str:
+ params: dict = {"File3D": {"Url": model_url, "Type": fmt}}
+ if preset is not None:
+ params["MotionType"] = preset.motion_type
+ r = _raise_for_error(call("SubmitAutoRiggingJob", params, service=SERVICE,
+ version=VERSION, creds=self._creds))
+ if "JobId" not in r:
+ raise JobFailedError(redact(f"提交绑骨没拿到 JobId: {r}"))
+ return str(r["JobId"])
+
+ def _wait(self, job_id: str) -> list:
+ for _ in range(max(1, int(self._max_min * 60 // self._poll))):
+ r = _raise_for_error(call("DescribeAutoRiggingJob", {"JobId": job_id},
+ service=SERVICE, version=VERSION, creds=self._creds,
+ idempotent=True))
+ status = str(r.get("Status") or "")
+ if status == "DONE":
+ files = r.get("ResultFile3Ds") or []
+ if not files:
+ raise JobFailedError(f"绑骨完成但无产物(JobId={job_id})")
+ return files
+ if status == "FAIL":
+ raise JobFailedError(redact(f"绑骨失败(JobId={job_id}): {r.get('ErrorMessage') or r}"))
+ if status.upper() not in _RUNNING:
+ raise JobFailedError(
+ f"绑骨返回未知状态 {status!r}(JobId={job_id}): {r} —— "
+ "任务可能仍在跑,拿这个 JobId 再查一次。")
+ time.sleep(self._poll)
+ raise JobTimeoutError(
+ f"绑骨轮询 {self._max_min} 分钟仍未出结果(JobId={job_id});积分可能已经扣了")
diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py
new file mode 100644
index 00000000..0733dde8
--- /dev/null
+++ b/backend/packages/framework/src/windup_framework/providers/sufy.py
@@ -0,0 +1,471 @@
+"""Provider 接口的 SUFY / qnaigc(Modelink 网关)同步实现。
+
+本模块实现三个 provider:视频(i2v)、图像(文生图 / 图生图)、以及它们共用的下载与首帧
+处理。抠图另在 :mod:`.matte`。
+
+视频走 OpenAI 风格面(:class:`SufyVideoProvider`),首帧是 base64 dataURI::
+
+ POST /v1/videos {model, prompt, size, seconds, mode, input_reference}
+ 轮询 GET /v1/videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4
+
+2026-07-27 对 kling-v2-5-turbo 端到端实测到 completed。
+
+图像走 OpenAI 兼容的 ``/chat/completions``(:class:`SufyImageProvider`),参考图以 data URI
+塞进 ``content`` 数组 —— 与视频的提交-轮询-下载三段式完全不同的调用形状。
+
+**网关上还有另一套 FAL 队列面**(veo / seedance / vidu 只在那一面)。曾实现过,但因为
+从未被真实调用过而移除,见本文件中段那条注释里记下的两个实测事实。
+
+型号与 key / base_url 均由 ``AIProviderSettings`` 注入,provider 内不读 env;哪个模型吃
+什么请求字段属该模型的 API 事实,写在代码里而不是配置里(填错只会在生成阶段才 failed,
+而费用可能已产生)。重依赖(PIL)惰性导入,保证模块导入零成本。
+"""
+from __future__ import annotations
+
+import base64
+from datetime import datetime, timezone
+from email.utils import parsedate_to_datetime
+import io
+import json
+import logging
+import math
+import re
+import time
+
+import httpx
+
+from windup_framework.config.provider import AIProviderSettings, settings
+
+from .interfaces import ImageProvider, VideoProvider
+
+logger = logging.getLogger("windup.providers.sufy")
+
+# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。
+_IMAGE_LIST_MODELS = ("kling-video-o1",)
+DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo"
+
+
+def _fit_first_frame(frame: bytes, size: str) -> bytes:
+ """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) bytes。
+
+ 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64
+ 会 VENDOR_FAILED(实测)。
+
+ 这一步同时是 kling 系"输出画幅"的唯一控制点:kling 的 i2v 端点没有 resolution/size
+ 字段,成片画幅跟随首帧,所以 ``size`` 只能在这里生效。
+ """
+ from PIL import Image
+
+ w, h = (int(x) for x in size.split("x"))
+ im = Image.open(io.BytesIO(frame)).convert("RGB")
+ pad = im.getpixel((0, 0))
+ fitted = im.copy()
+ fitted.thumbnail((w, h), Image.LANCZOS)
+ canvas = Image.new("RGB", (w, h), pad)
+ canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2))
+ buf = io.BytesIO()
+ canvas.save(buf, "JPEG", quality=90)
+ return buf.getvalue()
+
+
+def _first_frame_datauri(frame: bytes, size: str) -> str:
+ """首帧 → base64 dataURI(OpenAI 风格 ``/v1/videos`` 面专用;FAL 面不吃 dataURI)。"""
+ return "data:image/jpeg;base64," + base64.b64encode(_fit_first_frame(frame, size)).decode()
+
+
+class SufyVideoProvider(VideoProvider):
+ """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。"""
+
+ def __init__(
+ self,
+ config: AIProviderSettings = settings,
+ model: str | None = None,
+ mode: str = "std",
+ poll_interval: float = 60.0,
+ max_min: int = 30,
+ ) -> None:
+ # 轮询间隔必须 > 0:下面用 `max_min * 60 // poll` 算预算次数,传 0 直接除零
+ # (2026-08-11 补 i2v 主流程测试时逮到)。0 的语义本身也不成立 —— 那是忙等,
+ # 会把网关打满。测试要跑快就把 time.sleep 打桩掉,别把间隔设成 0。
+ if poll_interval <= 0:
+ raise ValueError(f"poll_interval 必须为正数,收到 {poll_interval}")
+ self._cfg = config
+ self._model = model or config.video_model
+ self._mode = mode
+ self._poll = poll_interval
+ self._max_min = max_min
+
+ def _client(self) -> httpx.Client:
+ return httpx.Client(
+ base_url=self._cfg.normalized_base_url,
+ headers={"Authorization": f"Bearer {self._cfg.api_key}"},
+ timeout=self._cfg.timeout,
+ )
+
+ def i2v(
+ self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720"
+ ) -> bytes:
+ body: dict = {
+ "model": self._model,
+ "prompt": prompt,
+ "size": size,
+ "seconds": str(seconds),
+ "mode": self._mode,
+ }
+ if self._model in _IMAGE_LIST_MODELS:
+ b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1]
+ body["image_list"] = [{"image": b64}]
+ else:
+ body["input_reference"] = _first_frame_datauri(first_frame, size)
+
+ with self._client() as client:
+ job = client.post("/videos", json=body).raise_for_status().json()
+ jid = job.get("id")
+ url = None
+ for _ in range(max(1, int(self._max_min * 60 // self._poll))):
+ time.sleep(self._poll)
+ st = client.get(f"/videos/{jid}").raise_for_status().json()
+ status = st.get("status")
+ if status == "completed":
+ vids = (st.get("task_result") or {}).get("videos") or []
+ url = vids[0].get("url") if vids else None
+ break
+ if status in ("failed", "cancelled"):
+ raise RuntimeError(f"i2v 失败: {status} — {st.get('error')}")
+ if not url:
+ raise RuntimeError("i2v 未取得视频 URL(超时或失败)")
+ return _download(client, url)
+
+
+class IncompleteDownloadError(RuntimeError):
+ """视频下载到的字节数与 ``Content-Length`` 不符。"""
+
+
+class UnsafeDownloadUrlError(RuntimeError):
+ """成品 URL 的协议不是 http(s) —— 不下载。
+
+ 这个 URL 来自网关响应,是外部输入。直接丢给 httpx 去 GET 一个 ``file://`` / ``data:``
+ 只会在重试三次之后报一个跟协议无关的传输错,不如在这里就说清是地址不对。
+ """
+
+
+def _same_origin(url: httpx.URL, other: httpx.URL) -> bool:
+ """同源判定(scheme + host + 端口,默认端口按 scheme 补齐)。
+
+ 语义对齐 httpx 自己在跨源重定向时摘凭证用的 ``Client._redirect_headers``;
+ 没直接 import 它的私有 ``_same_origin``,免得被上游改名。
+
+ "默认端口补齐"这一步在 httpx 0.28 下其实判不出新差别(它已把 ``:443`` / ``:80``
+ 归一化成 ``port is None``,2026-08-10 变异测试确认单独拆掉这行无用例失败)。留着的理由
+ 是与 httpx 保持同一套判据:一旦上游不再归一化,少了它 ``https://gw`` 与 ``https://gw:443``
+ 就成了跨源,会把该带的凭证摘掉、把同源下载打成 401。
+ """
+ default = {"http": 80, "https": 443}
+ return (
+ url.scheme == other.scheme
+ and url.host == other.host
+ and (url.port or default.get(url.scheme)) == (other.port or default.get(other.scheme))
+ )
+
+
+def _download_request(client: httpx.Client, url: str) -> httpx.Request:
+ """构造成品下载请求;目标不在网关同源时,把 client 级凭证摘掉。
+
+ 为什么必须摘(2026-08-10 机器审提出):成品 URL 是**网关响应里的绝对地址**,正常情况
+ 指向 CDN 域名,异常情况可以是网关返回的任意地址。而 httpx 只在跨源**重定向**时才自动
+ 摘 Authorization,对这种一开始就跨源的直连请求,client 级 headers 会原样带过去 ——
+ 于是 ``Authorization: Bearer/Key `` 被发给了那个域名,等于把 API key 交出去。
+
+ 同源时保留凭证:网关也可能签发自己域名下的下载链接,那条路径摘了头就是 401。
+ 所以按目标地址判定,不是一律摘、也不是一律留。
+ """
+ request = client.build_request("GET", url)
+ if request.url.scheme not in ("http", "https"):
+ raise UnsafeDownloadUrlError(f"成品 URL 必须是 http(s),收到 {str(request.url)!r}")
+ if not _same_origin(request.url, client.base_url):
+ # 只摘目标域名不该看到的:Proxy-Authorization 是给代理的,与目标是否同源无关,别动它。
+ request.headers.pop("Authorization", None)
+ request.headers.pop("Cookie", None)
+ return request
+
+
+def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes:
+ """下载已生成好的视频,带重试 + 长度校验。
+
+ 为什么单次读取不够(2026-08-05 实测,同一角色连续两单复现):原实现是
+ ``client.get(url).raise_for_status().content``。**视频此时已经生成、费用已经产生**,
+ 只要读 body 时连接断一次,整单就废::
+
+ peer closed connection without sending complete message body
+ (received 720450 bytes, expected 929531)
+
+ 重试是安全的:这是对成品 URL 的 GET,幂等且不再计费——**代价是一次重下,
+ 不重试的代价是一次重新生成**。
+
+ 长度校验是因为截断不一定抛异常:服务端提前关流而客户端已收到部分 body 时,
+ ``.content`` 可能直接返回短 bytes,那样坏视频会一路流到出帧环节才暴露,
+ 在那里看起来像"解码失败",很难回溯到这里。``Content-Length`` 缺失(分块传输)时跳过校验。
+
+ 凭证处理见 :func:`_download_request`。请求在进循环之前就构造好:地址不合法要在
+ 发出任何一次请求之前炸,而不是重试三次之后。
+ """
+ request = _download_request(client, url)
+ last: Exception | None = None
+ for attempt in range(tries):
+ try:
+ # send 不会再合并 client 级 headers(build_request 时已合并过),
+ # 所以上面摘掉的 Authorization 不会被重新加回来。
+ response = client.send(request)
+ response.raise_for_status()
+ body = response.content
+ expected = response.headers.get("content-length")
+ if expected and len(body) != int(expected):
+ raise IncompleteDownloadError(f"视频下载不完整: {len(body)}/{expected} 字节")
+ return body
+ except (httpx.HTTPError, IncompleteDownloadError) as exc:
+ last = exc
+ if attempt < tries - 1:
+ time.sleep(2**attempt)
+ raise RuntimeError(f"视频下载失败(已重试 {tries} 次): {last}") from last
+
+
+# ── FAL 队列面 ──────────────────────────────────────────────────────────────
+# 2026-08-07 拉网关 OpenAPI spec 核对得到:平台的 22 个图生视频端点全在 /queue/ 下,
+# 首帧字段一律是 URL 形态(image_url / start_image_url),同日实测送 dataURI 无一能用。
+# (spec 里 seedance / vidu-q3 / kling-v3-turbo 三家的字段说明写着"URL 或 base64",
+# 与实测冲突,未复验。本实现一律只发公网 URL —— 那是 22 个端点的共同解。)
+#
+# 每家有三样东西不一样,而且**没有一条能靠拼字符串猜出来**,所以下面是一张硬表:
+# 1. 提交路径:型号段各不相同(o3 / v3 / v3/turbo / v2.6 / v2.5-turbo / o1),
+# 有的带 {mode} 路径参数、有的不带(veo / seedance / minimax / vidu 不带)。
+# 2. 首帧字段名:同是 kling,o3 与 v2.5-turbo 叫 image_url,v3 / v2.6 / o1 却叫
+# start_image_url。塞错字段 = 送了图但模型没收到。
+# 3. 轮询前缀:**不是**提交路径加个 /requests。kling 六个型号共用一个
+# /queue/fal-ai/kling-video/requests/{id},型号段与 mode 段都不出现。
+# 这一条是最容易想当然拼错的地方。
+#
+# 另有两处形态差异也写进表里,因为取值形式不同会被网关 400:
+# - 时长字段都叫 duration,但取值分三种形态:"5"(kling/seedance)、"8s"(veo)、
+# 5(minimax/vidu,整数)。
+# - 分辨率:kling 系**没有**这个字段(成片画幅跟随首帧,所以 size 只能靠补边生效);
+# 其余各家的档位枚举各不相同。
+
+
+# ── FAL 队列面(veo / seedance / vidu)已移除 ────────────────────────────────
+#
+# 曾有一整套 FalQueueVideoProvider + FirstFrameUploader + 端点映射表(412 行、28 条
+# 测试)。删掉的理由与 GenRoute 只列有实现的路线是同一条:**它从未被真实调用过**
+# —— app / ai_engine 里零引用,产品链路走不到,而"代码在仓里"会让人以为该能力已具备。
+#
+# 真要接 veo / seedance 时连同一次真实调用一起加回。届时的两个已知事实(实测挣得,
+# 别再摸索一遍):
+# 1. FAL 面只吃**公网 URL**,不吃 base64;塞 base64 会 status=queued 之后在生成阶段
+# 才 failed,费用可能已经产生。
+# 2. 鉴权头是 `Authorization: Key `,不是 `Bearer`;路径与 /v1 平级,不是它的子路径。
+# 归档实测记录见项目参考资料(图生视频 API 实测文档)。
+
+
+DEFAULT_IMAGE_MODEL = "gemini-2.5-flash-image"
+
+# "调用成功但没返回有效图"的重试次数。与 _download 的网络重试是两码事:那个治连接断,
+# 这个治模型返回了一条不含图的正常响应(实测偶发)。也是为什么下面要判 base64 长度 ——
+# 返回里可能带一个几十字节的占位串,当图存下去就是一个打不开的文件。
+_IMAGE_TRIES = 3
+_MIN_IMAGE_BYTES = 5000
+_CONNECT_RETRIES = 3
+_MAX_RETRY_WAIT = 30.0
+_IMAGE_TIMEOUT_MULTIPLIER = 1.5
+
+# 429 是被限流拒收、必然没计费,所以按次数放开重试。它与 _IMAGE_TRIES 会叠乘,单次
+# gen_image 的最坏情况因此是:_IMAGE_TRIES × _POST_TRIES = 9 次请求,退避最多睡
+# 6 × _MAX_RETRY_WAIT = 180 秒,加上每次请求自身 timeout × _IMAGE_TIMEOUT_MULTIPLIER。
+_POST_TRIES = 3
+
+# 521 源站拒绝连接、523 源站不可达都止步于 TCP 层;522 按 Cloudflare 自己的定义含两种
+# 情形 —— 握手没收到 SYN+ACK,以及连接已建立但源站未及时确认请求,后者请求已经写到源站。
+# 所以"重发不会重复计费"是大概率而非保证,重发次数因此要受 _UNREACHED_RESENDS 约束。
+#
+# 判据只看码、不看响应头:``AI_BASE_URL`` 后面挂的是哪家网关不可知,靠 ``cf-ray`` +
+# ``server: cloudflare`` 认 Cloudflare 会把真实链路上的 52x 全判否(实测网关自报
+# ``server: APISIX``),整条重试等于不存在。
+#
+# 520 与 524 不在此列:连接已建立、请求可能正在源站处理中(524 就是"源站 100 秒没答完"),
+# 重发一次就是为同一张图付两次钱。
+_CLOUDFLARE_UNREACHED_STATUS = frozenset({521, 522, 523})
+
+# 一次 gen_image 内允许把 52x 重发几次。只按码判就无法排除"网关转发给上游之后才回 52x",
+# 与其赌它不存在,不如把最坏情况封成一个小常数:最多多付两张图,且不随上面两层循环叠乘。
+_UNREACHED_RESENDS = 2
+
+_DIAGNOSTIC_HEADERS = ("server", "cf-ray", "via", "x-served-by", "retry-after")
+
+
+class _ResendBudget:
+ """跨 _post 的多次调用共享:叠乘的是循环次数,可重复计费的次数不该跟着叠乘。"""
+
+ def __init__(self) -> None:
+ self._left = _UNREACHED_RESENDS
+ self.spent = 0
+
+ def take(self) -> bool:
+ if self._left <= 0:
+ return False
+ self._left -= 1
+ self.spent += 1
+ return True
+
+
+def _edge_fingerprint(response: httpx.Response) -> str:
+ """52x 出自链路上哪一跳,只能从这几个头看 —— 不记下来,线上就只剩一个状态码可复盘。"""
+ seen = {k: response.headers.get(k) for k in _DIAGNOSTIC_HEADERS}
+ return " ".join(f"{k}={v}" for k, v in seen.items() if v) or "无可辨识的边缘响应头"
+
+
+def _utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _retry_after_seconds(value: str) -> float | None:
+ try:
+ delay = float(value)
+ except ValueError:
+ try:
+ retry_at = parsedate_to_datetime(value)
+ except (TypeError, ValueError, OverflowError):
+ return None
+ if retry_at.tzinfo is None:
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
+ delay = (retry_at.astimezone(timezone.utc) - _utc_now()).total_seconds()
+ if not math.isfinite(delay):
+ return None
+ return min(max(delay, 0.0), _MAX_RETRY_WAIT)
+
+
+def _retry_exhausted_message(status: int, tries: int, fingerprint: str) -> str:
+ """这条文本常常是线上唯一留下的失败记录,少一样就得靠猜是限流、还是哪一跳断的。"""
+ if status == 429:
+ return (
+ f"图像服务请求过于频繁(HTTP {status}),连发 {tries} 次均被限流;"
+ f"请稍后重试或检查服务商额度;{fingerprint}"
+ )
+ return (
+ f"图像网关未能连上上游(HTTP {status}),已重发 {tries} 次仍未通;"
+ f"再重发有重复计费风险,故停止;{fingerprint}"
+ )
+
+
+# 从响应里捞 data URI。模型把图放在 message.content 里,而不同网关的包裹层级不一样
+# (有的 content 是字符串、有的是 parts 数组),故对整个响应 JSON 做一次正则,
+# 不去猜层级 —— 猜错的代价是"调用成功、费用已产生、但我们说没图"。
+_DATA_URI = re.compile(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})")
+
+
+class SufyImageProvider(ImageProvider):
+ """文生图 / 图生图 provider(OpenAI 兼容的 ``/chat/completions`` 面)。
+
+ 调用形状与 i2v 那两个 provider 完全不同:图像走 chat 接口、参考图以 data URI 塞进
+ ``content`` 数组,没有提交-轮询-下载三段式。
+
+ 2026-08-10 修:此前 ``gen_image`` 直接抛 NotImplementedError,而
+ ``POST /generation/image`` 端点是可达的、``ImageTaskExecutor`` 又默认实例化本类 ——
+ 于是每个图像任务都稳定走到 FAILED。端点看着可用、实际必失败,正是本仓最忌讳的形态
+ (机器审逮到)。实现取自管线仓已跑通的通路(同日用它出过三张角色母版)。
+ """
+
+ def __init__(
+ self,
+ config: AIProviderSettings = settings,
+ model: str | None = None,
+ ) -> None:
+ self._cfg = config
+ self._model = model or config.image_model
+
+ def _client(self) -> httpx.Client:
+ return httpx.Client(
+ base_url=self._cfg.normalized_base_url,
+ headers={"Authorization": f"Bearer {self._cfg.api_key}"},
+ timeout=self._cfg.timeout * _IMAGE_TIMEOUT_MULTIPLIER,
+ # retries 只覆盖建连阶段的失败(SSL 握手、连接被重置)。本机走代理时这类抖动
+ # 常见,已跑通的管线实现正是靠一层网络重试扛住的;不加会在人家能恢复的地方
+ # 放弃。它不重试读超时与 5xx —— 那两种请求可能已达上游,重发会重复计费。
+ transport=httpx.HTTPTransport(retries=_CONNECT_RETRIES),
+ )
+
+ def _post(self, client: httpx.Client, body: dict, resends: _ResendBudget) -> dict:
+ """发送请求,只重试大概率没被上游收下的失败(429 与 521/522/523)。
+
+ 为什么把 400 / 404 单独挑出来说:同一把 key 下不同网关的模型目录**不一样**。实测
+ ``GET /v1/models``:一个网关 73 个模型、一个图像模型都没有;另一个 134 个、
+ 含本模块默认的那个(2026-08-10)。配错 ``AI_BASE_URL`` 时原始报错只是一条
+ 404,读的人无从知道该去改配置还是改模型名。
+ """
+ for attempt in range(1, _POST_TRIES + 1):
+ resp = client.post(self._cfg.chat_completions_path, json=body)
+ code = resp.status_code
+ edge = _edge_fingerprint(resp)
+ if code in _CLOUDFLARE_UNREACHED_STATUS and not resends.take():
+ raise RuntimeError(_retry_exhausted_message(code, resends.spent, edge))
+ retryable = code == 429 or code in _CLOUDFLARE_UNREACHED_STATUS
+ if not retryable:
+ # 5xx 一律留指纹:要不要人工重发,取决于失败落在链路的哪一跳。
+ if code >= 500:
+ logger.warning(
+ "图像服务返回 %d,不重发(无法排除请求已到达上游并计费);%s",
+ code, edge,
+ )
+ break
+ if attempt == _POST_TRIES:
+ raise RuntimeError(_retry_exhausted_message(code, _POST_TRIES, edge))
+ delay = _retry_after_seconds(resp.headers.get("Retry-After", ""))
+ if delay is None:
+ # 上限同样兜住指数退避:上游挂掉时不该把一个图像任务堵成长时间阻塞。
+ delay = min(float(2**attempt), _MAX_RETRY_WAIT)
+ logger.warning(
+ "图像服务返回 %d,第 %d/%d 次请求,%.2f 秒后重试;%s",
+ code,
+ attempt,
+ _POST_TRIES,
+ delay,
+ edge,
+ )
+ time.sleep(delay)
+ if resp.status_code in (400, 404):
+ raise RuntimeError(
+ f"网关 {self._cfg.normalized_base_url} 拒绝了模型 {self._model!r}"
+ f"(HTTP {resp.status_code})。先确认该网关的目录里有它:"
+ f"GET {self._cfg.normalized_base_url}/models —— 不同网关目录不同,"
+ f"同一把 key 也是。原始响应:{resp.text[:200]}"
+ )
+ return resp.raise_for_status().json()
+
+ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes:
+ """提示词 + 参考图 → 一张 PNG bytes。拿不到有效图就抛,不返回空 bytes。
+
+ 为什么不返回空 bytes 兜底:上游 ``ImageTaskExecutor`` 会把返回值直接上传对象存储
+ 并写进任务结果,一个 0 字节的"成功"会变成用户看到的一张裂图。
+ """
+ content: list[dict] = [{"type": "text", "text": prompt}]
+ for raw in refs:
+ b64 = base64.b64encode(raw).decode()
+ content.append({
+ "type": "image_url",
+ "image_url": {"url": f"data:image/png;base64,{b64}"},
+ })
+ body = {"model": self._model, "messages": [{"role": "user", "content": content}]}
+
+ last = ""
+ # 预算建在循环外:同一张图的多次尝试共用一份"可能已计费"的额度。
+ resends = _ResendBudget()
+ with self._client() as client:
+ for attempt in range(1, _IMAGE_TRIES + 1):
+ payload = self._post(client, body, resends)
+ found = _DATA_URI.search(json.dumps(payload))
+ if found:
+ data = base64.b64decode(found.group(1))
+ if len(data) >= _MIN_IMAGE_BYTES:
+ return data
+ last = f"图只有 {len(data)} 字节(下限 {_MIN_IMAGE_BYTES})"
+ else:
+ last = "响应里没有 data URI"
+ logger.warning("文生图第 %d/%d 次没拿到有效图:%s", attempt, _IMAGE_TRIES, last)
+ raise RuntimeError(f"文生图 {_IMAGE_TRIES} 次均未取得有效图:{last}")
diff --git a/backend/packages/framework/src/windup_framework/search/.gitkeep b/backend/packages/framework/src/windup_framework/search/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 01029a56..0bb5cbc1 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -44,4 +44,7 @@ forbidden_modules = ["windup_ai_engine"]
# ── pytest 配置 ─────────────────────────────────────────────────────
[tool.pytest.ini_options]
+markers = [
+ "slow: 要真实产物或浏览器,缺件时显式 skip",
+]
addopts = "--cov=packages --cov-report=term-missing"
diff --git a/backend/scripts/export_openapi.py b/backend/scripts/export_openapi.py
new file mode 100644
index 00000000..10b328c5
--- /dev/null
+++ b/backend/scripts/export_openapi.py
@@ -0,0 +1,32 @@
+"""Export the FastAPI OpenAPI schema to the repository root."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+os.environ.setdefault("JWT_SECRET", "openapi-export-only-secret-32chars")
+os.environ.setdefault("POSTGRES_PASSWORD", "openapi-export-only-password")
+
+from windup_app.bootstrap.app import create_app
+
+
+OPENAPI_PATH = Path(__file__).resolve().parents[2] / "openapi.json"
+
+
+def main() -> None:
+ app = create_app()
+ try:
+ schema = app.openapi()
+ finally:
+ app.state.generation_dispatcher.shutdown()
+
+ OPENAPI_PATH.write_text(
+ json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 6a120038..714cbfba 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -5,35 +5,126 @@
engine 上(不碰全局 Postgres engine)。
"""
+import os
+import pathlib
+
+# CI 环境可能未配置真实凭据,在 import 触发 Settings 实例化前提供测试默认值。
+# setdefault 不覆盖已有的环境变量(本地 .env 或 CI secrets 优先生效)。
+os.environ.setdefault("JWT_SECRET", "test-secret-key-for-ci-only-32chars")
+os.environ.setdefault("POSTGRES_PASSWORD", "testpassword123")
+
import pytest
from fastapi.testclient import TestClient
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from windup_app.bootstrap.app import create_app
from windup_app.server.character.model import Character
from windup_app.server.project.model import Project
+from windup_app.server.quota.model import (
+ CreditAccount,
+ CreditTransaction,
+ InviteCode,
+ InviteRecord,
+)
from windup_app.server.user.model import User
+from windup_app.server.orchestrator.model import GenerationTaskRecord
from windup_app.server.workflow_run.model import WorkflowRun
from windup_app.server.user.service import create_access_token
+from windup_framework.config.quota import settings as quota_settings
from windup_framework.db import Base, get_session
+def insert_project(session, **overrides) -> Project:
+ """写入一条合法项目,供需要 ``windup_character.project_id`` 外键的测试使用。"""
+ fields = {
+ "user_id": 1,
+ "project_name": "测试项目",
+ "character_perspective": 1,
+ "directional_movement": 2,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ }
+ fields.update(overrides)
+ project = Project(**fields)
+ session.add(project)
+ session.flush()
+ return project
+
+
+def _disable_generation_execution(app):
+ app.state.run_action_task = lambda *args: None
+ app.state.run_image_task = lambda *args: None
+
+
+def seed_invite_code(session, code: str = "AB23CD45") -> str:
+ """预置一个可重复使用的邀请码,供注册测试使用。"""
+ inviter = User(email=f"inviter-{code.lower()}@example.com", password_hash="x")
+ session.add(inviter)
+ session.flush()
+ session.add(InviteCode(user_id=inviter.id, code=code, used_count=0))
+ seed_credit_account(session, inviter.id)
+ return code
+
+
+def seed_credit_account(
+ session, user_id: int, *, balance: int | None = None
+) -> CreditAccount:
+ """给测试用户补一张积分账户(注册赠送口径)。"""
+ gift = quota_settings.register_gift_amount
+ account = CreditAccount(
+ user_id=user_id,
+ balance=gift if balance is None else balance,
+ frozen=0,
+ total_earned=gift,
+ total_spent=0,
+ )
+ session.add(account)
+ session.flush()
+ return account
+
+
def _make_engine():
"""单连接内存 SQLite;``check_same_thread=False`` 让 TestClient 线程可共用。"""
- return create_engine(
+ engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
+ @event.listens_for(engine, "connect")
+ def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record):
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA foreign_keys=ON")
+ cursor.close()
+
+ return engine
+
+
+@pytest.fixture()
+def invite_code(db_session):
+ return seed_invite_code(db_session)
+
@pytest.fixture()
def engine():
"""建好 ``windup_project`` 和 ``windup_user`` 表的内存 engine。"""
engine = _make_engine()
- Base.metadata.create_all(engine, tables=[Project.__table__, User.__table__, Character.__table__, WorkflowRun.__table__])
+ Base.metadata.create_all(
+ engine,
+ tables=[
+ Project.__table__,
+ User.__table__,
+ Character.__table__,
+ WorkflowRun.__table__,
+ CreditAccount.__table__,
+ CreditTransaction.__table__,
+ InviteCode.__table__,
+ InviteRecord.__table__,
+ GenerationTaskRecord.__table__,
+ ],
+ )
yield engine
engine.dispose()
@@ -69,8 +160,10 @@ def override_get_session():
session.close()
app = create_app()
+ _disable_generation_execution(app)
app.dependency_overrides[get_session] = override_get_session
yield TestClient(app)
+ app.state.generation_dispatcher.shutdown()
app.dependency_overrides.clear()
@@ -94,6 +187,7 @@ def override_get_session():
session.close()
app = create_app()
+ _disable_generation_execution(app)
app.dependency_overrides[get_session] = override_get_session
# 生成测试用 token
@@ -101,6 +195,7 @@ def override_get_session():
client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
yield client
+ app.state.generation_dispatcher.shutdown()
app.dependency_overrides.clear()
@@ -121,10 +216,40 @@ def override_get_session():
session.close()
app = create_app()
+ _disable_generation_execution(app)
app.dependency_overrides[get_session] = override_get_session
token = create_access_token(2, "other@example.com")
client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
yield client
+ app.state.generation_dispatcher.shutdown()
app.dependency_overrides.clear()
+
+
+# ── 三渲二 provider 的 fixture(随 provider3d 一起迁入)──────────────────
+#
+# 集成用例要真模型(减面 GLB / 已绑骨 FBX)。这些产物是跑过付费链路才有的、体积几十 MB,
+# 不进仓;用 WINDUP_RENDER3D_ARTIFACTS 指向它们所在目录即可跑全套(实测 82/82 全过)。
+# 不指就按仓内默认路径找,找不到**显式 skip 并打印缺哪个文件** —— 不静默当通过。
+# 合成 GLB 的构造器在 tests/render3d_helpers.py(真实产物只有几个,覆盖不到边界)。
+_ARTIFACTS = pathlib.Path(
+ os.getenv("WINDUP_RENDER3D_ARTIFACTS")
+ or pathlib.Path(__file__).resolve().parents[2] / "characters" / "oc_v4"
+)
+RIGGED_FBX = _ARTIFACTS / "rigged_despill.fbx"
+DECIMATED_GLB = _ARTIFACTS / "model_std_draw15k.glb"
+
+
+@pytest.fixture(scope="session")
+def rigged_fbx() -> bytes:
+ if not RIGGED_FBX.exists():
+ pytest.skip(f"缺已绑骨产物 {RIGGED_FBX}")
+ return RIGGED_FBX.read_bytes()
+
+
+@pytest.fixture(scope="session")
+def decimated_glb() -> bytes:
+ if not DECIMATED_GLB.exists():
+ pytest.skip(f"缺减面产物 {DECIMATED_GLB}")
+ return DECIMATED_GLB.read_bytes()
diff --git a/backend/tests/render3d_helpers.py b/backend/tests/render3d_helpers.py
new file mode 100644
index 00000000..66eec5af
--- /dev/null
+++ b/backend/tests/render3d_helpers.py
@@ -0,0 +1,54 @@
+"""三渲二测试用的 GLB 构造器(随 provider3d 一起迁入)。"""
+from __future__ import annotations
+
+import json
+import struct
+
+
+def make_glb(
+ bbox_min=(-0.35, 0.0, -0.14),
+ bbox_max=(0.35, 1.0, 0.14),
+ *,
+ mesh_name: str = "body",
+ node_name: str = "character",
+ material_name: str = "skin",
+ scale: tuple[float, float, float] | None = None,
+ extra_mesh: tuple[str, tuple, tuple] | None = None,
+ pad: int = 0,
+) -> bytes:
+ """造一个**只有 JSON 块**是真的 GLB。
+
+ ``check_model`` 只读 JSON 块里 accessor 的 min/max(包围盒在 glTF 里是白送的),
+ 所以 BIN 块不需要有真顶点 —— 这让边界用例可以随手造,不必真去生成网格。
+ ``pad`` 用来把文件撑大以测体积闸。
+ """
+ nodes = [{"name": node_name, "mesh": 0}]
+ if scale:
+ nodes[0]["scale"] = list(scale)
+ meshes = [{"name": mesh_name, "primitives": [{"attributes": {"POSITION": 0}, "material": 0}]}]
+ accessors = [{"type": "VEC3", "componentType": 5126, "count": 8,
+ "min": list(bbox_min), "max": list(bbox_max)}]
+ materials = [{"name": material_name}]
+ if extra_mesh:
+ name, emin, emax = extra_mesh
+ nodes.append({"name": name, "mesh": 1})
+ meshes.append({"name": name, "primitives": [{"attributes": {"POSITION": 1}, "material": 1}]})
+ accessors.append({"type": "VEC3", "componentType": 5126, "count": 8,
+ "min": list(emin), "max": list(emax)})
+ materials.append({"name": f"{name}_mat"})
+
+ doc = {
+ "asset": {"version": "2.0"},
+ "scene": 0,
+ "scenes": [{"nodes": list(range(len(nodes)))}],
+ "nodes": nodes, "meshes": meshes, "accessors": accessors, "materials": materials,
+ }
+ payload = json.dumps(doc).encode()
+ payload += b" " * (-len(payload) % 4)
+ binchunk = b"\x00" * (pad + (-pad % 4))
+ total = 12 + 8 + len(payload) + (8 + len(binchunk) if binchunk else 0)
+ out = struct.pack("<4sII", b"glTF", 2, total)
+ out += struct.pack(" bytes:
+ """一张带主体的小 RGBA PNG(四周留透明边,供真实对齐 / 抠图链处理)。"""
+ img = Image.new("RGBA", (64, 96), (0, 0, 0, 0))
+ for y in range(20, 80):
+ for x in range(24 + shift, 40 + shift):
+ img.putpixel((x, y), color)
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _NullProgress:
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ pass
+
+
+class _MockWalkStrategy(DerivationStrategy):
+ """顶替真实 VideoFrameStrategy:返回 N 张真 PNG,让对齐真跑。"""
+
+ route = GenRoute.VIDEO_I2V
+
+ def derive(self, card, action, master, progress) -> list[bytes]:
+ return [_tiny_png() for _ in range(action.n_frames)]
+
+
+def _make_generator() -> CharacterGenerator:
+ return CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()})
+
+
+def test_route_matrix_is_the_measured_contract():
+ # 实测挣得的架构决策:走路/跑/攻击走视频,受击逐帧,待机程序化
+ assert ROUTE_MATRIX[ActionType.WALK] is GenRoute.VIDEO_I2V
+ assert ROUTE_MATRIX[ActionType.RUN] is GenRoute.VIDEO_I2V
+ assert ROUTE_MATRIX[ActionType.ATTACK] is GenRoute.VIDEO_I2V
+ assert ROUTE_MATRIX[ActionType.CUSTOM] is GenRoute.VIDEO_I2V
+ assert ROUTE_MATRIX[ActionType.JUMP] is GenRoute.VIDEO_I2V
+ assert ROUTE_MATRIX[ActionType.HIT] is GenRoute.PER_FRAME
+ assert ROUTE_MATRIX[ActionType.IDLE] is GenRoute.VIDEO_I2V
+
+
+def test_generate_walk_is_wired_end_to_end():
+ card = CharacterCard(name="rogue", desc="hooded ranger, dual daggers")
+ # 视频路线只需声明帧数;poses 是逐帧路线的入参,这里不传(以前必须编 8 条假描述)。
+ action = ActionSpec(action=ActionType.WALK, n_frames=8)
+ out = _make_generator().generate(card, action, master=_tiny_png(), progress=_NullProgress())
+ assert isinstance(out, GeneratedAction)
+ assert len(out.frames) == 8 # 选路线→derive→对齐 全串通
+ assert len(out.durations) == 8 # 逐帧时长与帧等长
+ # 时长是按动作查表来的,不是从入参帧率算的 —— walk 的基准是 125ms/帧。
+ # 原先这里断言的是 `out.fps == action.fps`,把"照抄一个不生效的入参"锁成了契约。
+ assert all(d > 0 for d in out.durations)
+ assert set(out.durations) == {DEFAULT_FPS_MS["walk"]}
+ assert all(f and f[:8] == b"\x89PNG\r\n\x1a\n" for f in out.frames) # 真 PNG
+
+
+def test_action_spec_stylize_defaults_and_toggle():
+ # 像素化是开关(默认 pixel),可关成 none 保留 i2v 画风
+ assert ActionSpec(action=ActionType.WALK).stylize is Stylize.PIXEL
+ a = ActionSpec(action=ActionType.WALK, stylize="none")
+ assert a.stylize is Stylize.NONE
+
+
+def _offline_video_strategy(monkeypatch, video=None) -> VideoFrameStrategy:
+ """离线版 VideoFrameStrategy:抽帧被顶替,不解码 mp4 / 不联网 / 不花钱。"""
+ dense = [Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") for i in range(24)]
+ monkeypatch.setattr(
+ "windup_ai_engine.strategy.concrete.extract_all_frames_bytes",
+ lambda video, cap=150: dense,
+ )
+
+ class _StubVideo:
+ def i2v(self, first_frame, prompt, seconds=5, size="1280x720"):
+ return b"fake-mp4"
+
+ class _StubMatte:
+ def cutout(self, frame): # 透传:合成帧已带 alpha
+ return frame
+
+ return VideoFrameStrategy(video or _StubVideo(), _StubMatte())
+
+
+def test_video_strategy_derive_runs_offline(monkeypatch):
+ """真实 VideoFrameStrategy.derive 离线跑通。
+
+ 证明 derive 的真实链路:i2v → 抽帧 → 抠图 → 选帧 → 出帧,产物是合法 RGBA PNG。
+ """
+ strat = _offline_video_strategy(monkeypatch)
+ card = CharacterCard(name="knight", desc="plate armor, sword")
+ action = ActionSpec(action=ActionType.WALK, stylize="none", n_frames=8)
+ out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress())
+ assert out and all(f[:8] == b"\x89PNG\r\n\x1a\n" for f in out)
+
+
+def test_video_strategy_honours_n_frames_without_any_poses(monkeypatch):
+ """帧数由 ActionSpec.n_frames 决定,**不必传 poses** —— A2 的落地验证。
+
+ 以前只能靠 len(poses) 表达帧数,于是"要 6 帧"得先编 6 条视频路线根本不读的姿势描述;
+ 读代码的人会以为那 6 条描述真的进了提示词。
+ """
+ strat = _offline_video_strategy(monkeypatch)
+ card = CharacterCard(name="knight", desc="plate armor, sword")
+ for n in (4, 6, 11):
+ action = ActionSpec(action=ActionType.WALK, stylize="none", n_frames=n)
+ out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress())
+ assert len(out) == n, f"要 {n} 帧,实得 {len(out)} 帧"
+
+
+def test_video_strategy_stylize_switch_actually_changes_the_pixels(monkeypatch):
+ """stylize 分支不能接反 —— 只验"两条分支都不抛错"验不出接反。
+
+ none=原样出帧(与输入同尺寸);pixel=裁包围盒 + 重采样到目标像素高,尺寸必然不同。
+ """
+ strat = _offline_video_strategy(monkeypatch)
+ card = CharacterCard(name="knight", desc="plate armor, sword")
+ plain = strat.derive(
+ card, ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4),
+ master=_tiny_png(), progress=_NullProgress(),
+ )
+ pixel = strat.derive(
+ card, ActionSpec(action=ActionType.WALK, stylize=Stylize.PIXEL, n_frames=4),
+ master=_tiny_png(), progress=_NullProgress(),
+ )
+ assert Image.open(io.BytesIO(plain[0])).size == (64, 96) # 未像素化:原尺寸
+ assert Image.open(io.BytesIO(pixel[0])).size != (64, 96) # 像素化:重采样过
+
+
+def test_video_strategy_prompt_follows_facing(monkeypatch):
+ """喂给 i2v 的提示词随 ActionSpec.facing 走 —— 朝向约束真的传到了付费调用那一层。
+
+ 这是 facing 枚举化要保护的东西:枚举保证值合法,本测保证合法值被用对。
+ """
+ seen: list[str] = []
+
+ class _SpyVideo:
+ def i2v(self, first_frame, prompt, seconds=5, size="1280x720"):
+ seen.append(prompt)
+ return b"fake-mp4"
+
+ strat = _offline_video_strategy(monkeypatch, video=_SpyVideo())
+ card = CharacterCard(name="knight", desc="plate armor, sword")
+ for facing in (Facing.SIDE, Facing.FRONT):
+ strat.derive(
+ card,
+ ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4, facing=facing),
+ master=_tiny_png(), progress=_NullProgress(),
+ )
+ assert "SIDE VIEW facing right" in seen[0]
+ assert "FACING THE VIEWER" in seen[1]
+
+
+def test_video_strategy_uses_custom_action_text(monkeypatch):
+ """自定义动作的描述要真的进提示词。"""
+ seen: list[str] = []
+
+ class _SpyVideo:
+ def i2v(self, first_frame, prompt, seconds=5, size="1280x720"):
+ seen.append(prompt)
+ return b"fake-mp4"
+
+ strat = _offline_video_strategy(monkeypatch, video=_SpyVideo())
+ strat.derive(
+ CharacterCard(name="hero", desc=""),
+ ActionSpec(
+ action=ActionType.CUSTOM,
+ custom_action="wave hello with the right hand",
+ cyclic=False,
+ stylize=Stylize.NONE,
+ n_frames=4,
+ ),
+ master=_tiny_png(),
+ progress=_NullProgress(),
+ )
+
+ assert "wave hello with the right hand" in seen[0]
+ assert "SIDE VIEW facing right" in seen[0]
+
+
+def test_real_video_strategy_is_registered_for_video_route():
+ # 真实 VideoFrameStrategy 可构造且声明视频路线(derive 联网,不在此跑)
+ class _V:
+ def i2v(self, first_frame, prompt, seconds=5, size="1280x720"):
+ return b""
+
+ class _M:
+ def cutout(self, frame):
+ return frame
+
+ strat = VideoFrameStrategy(_V(), _M())
+ assert strat.route is GenRoute.VIDEO_I2V
+
+
+# ── 未实现的路线必须炸,不能吐空帧(2026-08-07)─────────────────────────────
+#
+# 旧行为:PerFrameStrategy.derive 返回 [b""] * n_frames,CharacterGenerator._lastmile
+# 见到空帧就静默跳过对齐、原样返回。调用方拿到的 GeneratedAction 帧数对、时长对、
+# 无异常 —— 完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进
+# character_data,用户看到 N 张裂图,且排查时不会想到是"路线没实现"。
+#
+# 新行为:在最早能判定的边界上抛错。下面三条分别覆盖三个入口。
+
+
+def test_unimplemented_route_raises_instead_of_returning_empty_frames():
+ """PerFrameStrategy 调用即抛,不返回空帧。"""
+ import pytest
+
+ from windup_ai_engine.strategy import PerFrameStrategy
+
+ s = PerFrameStrategy(image=None, matte=None)
+ card = CharacterCard(name="t", desc="t")
+ action = ActionSpec(action=ActionType.HIT, poses=["a", "b", "c"])
+ with pytest.raises(NotImplementedError, match="per_frame"):
+ s.derive(card, action, _tiny_png(), _NullProgress())
+
+
+def test_missing_strategy_for_route_raises_with_what_is_wired():
+ """装配表里没有该路线时抛错,并报出已装配了哪些 —— 便于定位是漏注入还是没实现。"""
+ import pytest
+
+ # 只装 VIDEO_I2V,请求 hit(分流到 PER_FRAME)
+ gen = CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()})
+ card = CharacterCard(name="t", desc="t")
+ action = ActionSpec(action=ActionType.HIT, poses=["a", "b"])
+ with pytest.raises(NotImplementedError, match="video_i2v"):
+ gen.generate(card, action, _tiny_png(), _NullProgress())
+
+
+def test_empty_frames_from_strategy_are_rejected():
+ """strategy 吐出空帧(provider / 抠图坏了)时同样要炸,不原样放行。"""
+ import pytest
+
+ class _EmptyStrategy(DerivationStrategy):
+ route = GenRoute.VIDEO_I2V
+
+ def derive(self, card, action, master, progress) -> list[bytes]:
+ return [b"", b"", b""]
+
+ gen = CharacterGenerator({GenRoute.VIDEO_I2V: _EmptyStrategy()})
+ card = CharacterCard(name="t", desc="t")
+ action = ActionSpec(action=ActionType.WALK, poses=["a", "b", "c"])
+ with pytest.raises(ValueError, match="空帧"):
+ gen.generate(card, action, _tiny_png(), _NullProgress())
+
+
+def test_short_frame_count_from_strategy_is_rejected():
+ """产出帧数少于 ``n_frames`` 时要炸 —— 少给不会崩,只会"短一截"。
+
+ 这不是假想:slicing.pick_cycle / pick_oneshot 在源帧不足(i2v 视频太短 / 动作区间
+ 过窄)时 ``return frames`` / ``return span``,长度不足且不报错。时长表由
+ frame_durations(…, len(frames)) 现算,所以产物内部自洽 —— server 看不出异常,
+ 用户拿到一段步子没走完的循环。A2 之后 n_frames 是调用方的明确承诺,必须对账。
+ """
+ import pytest
+
+ class _ShortStrategy(DerivationStrategy):
+ route = GenRoute.VIDEO_I2V
+
+ def derive(self, card, action, master, progress) -> list[bytes]:
+ return [_tiny_png() for _ in range(action.n_frames - 1)] # 少给一帧
+
+ gen = CharacterGenerator({GenRoute.VIDEO_I2V: _ShortStrategy()})
+ card = CharacterCard(name="t", desc="t")
+ with pytest.raises(ValueError, match="要 8 帧,实际产出 7 帧"):
+ gen.generate(
+ card, ActionSpec(action=ActionType.WALK, n_frames=8),
+ _tiny_png(), _NullProgress(),
+ )
+
+
+def test_progress_notes_carry_enum_values_not_python_reprs():
+ """进度文案里不能出现 "ActionType.WALK"。
+
+ Python 3.11 改了 str-mixin 枚举的 __format__:f"{ActionType.WALK}" 从 "walk" 变成
+ "ActionType.WALK"(3.12.13 实测)。这串字经 server 变成用户看到的 SSE 进度文案,
+ 没有任何测试会因此变红 —— 属于"跑得通但对外是错的"那一类。
+ """
+ notes: list[str] = []
+
+ class _SpyProgress:
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ notes.append(note)
+
+ _make_generator().generate(
+ CharacterCard(name="t", desc="t"),
+ ActionSpec(action=ActionType.WALK, n_frames=4),
+ _tiny_png(), _SpyProgress(),
+ )
+ assert notes, "没收到任何进度上报"
+ assert not any("ActionType." in n for n in notes), notes
+ assert any("walk" in n for n in notes), notes
+
+
+class _SubSteppingStrategy(DerivationStrategy):
+ """按自己的刻度报 3 步 —— 与真实 VideoFrameStrategy 的上报形状一致。
+
+ ``_MockWalkStrategy`` 一步都不报,用它测不出跨层刻度问题:必须有个子组件真的
+ 往同一个 ProgressPort 上报自己的 (i, total)。
+ """
+
+ route = GenRoute.VIDEO_I2V
+
+ def derive(self, card, action, master, progress) -> list[bytes]:
+ progress.step("derive", 0, 3, "i2v 生成视频")
+ progress.step("derive", 1, 3, "抽帧 + 抠图")
+ progress.step("derive", 2, 3, "风格化")
+ return [_tiny_png() for _ in range(action.n_frames)]
+
+
+def _run_and_collect_progress() -> list[tuple[str, int, int]]:
+ seen: list[tuple[str, int, int]] = []
+
+ class _SpyProgress:
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ seen.append((stage, i, total))
+
+ CharacterGenerator({GenRoute.VIDEO_I2V: _SubSteppingStrategy()}).generate(
+ CharacterCard(name="t", desc="t"),
+ ActionSpec(action=ActionType.WALK, n_frames=4),
+ _tiny_png(), _SpyProgress(),
+ )
+ return seen
+
+
+def test_progress_reports_one_scale_end_to_end():
+ """整条生产线只能有一个 total。
+
+ 修之前 generator 报 i/4、中间夹着的 strategy 报 i/3,两套刻度混在同一个
+ ProgressPort 上,消费方按 i/total 画条会看到 totals={3,4}。
+ """
+ totals = {t for _, _, t in _run_and_collect_progress()}
+ assert len(totals) == 1, f"同一次生成里出现了多个 total: {sorted(totals)}"
+
+
+def test_progress_never_goes_backwards():
+ """进度不许倒退 —— 这是 #181 评审实跑逮到的那个症状。
+
+ 修之前实测倒退两次:route 25.0% → derive 0.0%、derive 66.7% → lastmile 50.0%。
+ """
+ seen = _run_and_collect_progress()
+ pcts = [i / t for _, i, t in seen]
+ back = [
+ (seen[k - 1], seen[k]) for k in range(1, len(pcts)) if pcts[k] < pcts[k - 1]
+ ]
+ assert not back, f"进度倒退 {len(back)} 次: {back}"
+
+
+def test_strategy_sub_progress_lands_inside_the_derive_band():
+ """子进度必须落在 derive 区间内,且区间内确实动了。
+
+ 只断言"不倒退"是不够的:把 _BandProgress 换成"永远报区间起点"也能通过那一条,
+ 进度条会在 derive 段整段卡住不动 —— 而 derive 是最慢的一段。
+ """
+ seen = _run_and_collect_progress()
+ derive = [i for stage, i, _ in seen if stage == "derive"]
+ assert derive, "没收到 derive 段的进度"
+ assert min(derive) >= _DERIVE_FROM and max(derive) <= _DERIVE_TO, derive
+ assert len(set(derive)) > 1, f"derive 段整段没动: {derive}"
+
+
+def test_generated_action_has_a_single_timing_source():
+ """出参不许有第二个描述播放速度的字段。
+
+ 此处曾有一条 ``test_loop_mode_currently_changes_nothing``,把"传 pingpong / none
+ 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒删注释"。
+ 那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个行为背书。
+ 2026-08-10 按机器审意见改成删字段 —— ``ActionSpec.loop`` 与 ``LoopMode`` 都已移除,
+ 真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。
+
+ 同批删掉的 ``GeneratedAction.fps`` 同理:它抄自入参、与 durations 互相矛盾。
+ """
+ from dataclasses import fields
+
+ names = {f.name for f in fields(GeneratedAction)}
+ assert "fps" not in names, "fps 与 durations 会给出两个不同的播放速度"
+ assert "durations" in names
+
+
+def test_genroute_only_lists_implemented_routes():
+ """GenRoute 只列有实现的路线 —— 没有实现的枚举值等于死代码。
+
+ 这条同时管住两个方向:
+ - PROC_IDLE(程序化待机,#53 原设计)已证否,连同 ProcIdleStrategy 一并移除;
+ - 未来路线(三渲二渲染出帧)**不提前留位**,契约需求记在 Issue,随实现一起加成员。
+ 枚举加成员是纯加法,不构成破坏性变更,所以"提前留位免得二次改形"不成立。
+ """
+ assert {r.value for r in GenRoute} == {"video_i2v", "per_frame", "render_3d"}
+ import windup_ai_engine.strategy as strat
+ assert not hasattr(strat, "ProcIdleStrategy")
+ # render_3d 是随实现一起加的(RenderFrameStrategy + executor 装配),不是提前留位 ——
+ # 真实装配那条断言在 test_orchestrator_hardening 里。
+ assert hasattr(strat, "RenderFrameStrategy")
+
+
+# ── 交付画布尺寸(2026-08-11 挣得)────────────────────────────────────────────
+#
+# 引擎此前恒出 256 方形,项目的 sprite 尺寸由上层再缩一次。那一步用 Image.thumbnail
+# 补边,而 thumbnail **只缩不放**:项目要 512 时 256 的帧根本不会被放大,而是原尺寸
+# 居中贴进 512 画布,于是 align_bottom_center 刚对齐好的脚线 0.92 被挪到 0.709
+# (实测),角色不站在地上、跨动作对齐一并失效。故 canvas 直接传进引擎。
+
+
+def _delivered(png: bytes):
+ """返回 (画布尺寸, 主体高, 脚线比例)。"""
+ import numpy as np
+
+ im = Image.open(io.BytesIO(png)).convert("RGBA")
+ ys, _ = np.nonzero(np.asarray(im)[:, :, 3] > 128)
+ return im.size, int(ys.max() - ys.min() + 1), (int(ys.max()) + 1) / im.height
+
+
+def _run(canvas=None):
+ card = CharacterCard(name="rogue", desc="hooded ranger")
+ action = ActionSpec(action=ActionType.WALK, n_frames=4)
+ gen = _make_generator()
+ kw = {} if canvas is None else {"canvas": canvas}
+ return gen.generate(card, action, master=_tiny_png(), progress=_NullProgress(), **kw)
+
+
+def test_canvas_omitted_keeps_the_256_default_byte_for_byte():
+ """不传 canvas → 与加这个参数之前逐字节相同(默认行为不变)。"""
+ a = _run()
+ b = _run(canvas=(256, 256))
+ assert [f for f in a.frames] == [f for f in b.frames]
+ assert _delivered(a.frames[0])[0] == (256, 256)
+
+
+def test_canvas_512_doubles_delivered_subject_height():
+ """指定 512 时交付帧主体高度约翻倍 —— 这就是"成品放大看很糊"的正解。"""
+ small = _delivered(_run().frames[0])
+ big = _delivered(_run(canvas=(512, 512)).frames[0])
+ assert big[0] == (512, 512)
+ assert abs(big[1] / small[1] - 2.0) < 0.05, f"期望约翻倍,实际 {small[1]} → {big[1]}"
+
+
+def test_canvas_non_square_is_honoured_end_to_end():
+ """非方形项目尺寸也要一次出到位:高度几何只看画布高,不被画布宽带偏。
+
+ 与同高的方形画布逐项比,而不是比一个算出来的期望值 —— 主体只有 60px 高,
+ 定标系数 5.4 倍,ref_height 上 1px 的取整差会被放大成 5px,拿绝对值卡阈值
+ 量的是取整噪声不是行为。
+ """
+ tall, height_t, foot_t = _delivered(_run(canvas=(384, 512)).frames[0])
+ square, height_s, foot_s = _delivered(_run(canvas=(512, 512)).frames[0])
+ assert tall == (384, 512) and square == (512, 512)
+ assert height_t == height_s, "画布高相同 → 主体高必须相同(高度几何不看宽)"
+ assert foot_t == foot_s
+ assert abs(foot_t - 0.92) <= 0.01
+
+
+def test_canvas_reaches_every_frame_not_just_the_first():
+ """整段每一帧都得是请求的画布 —— 半段没生效比不生效更难查。"""
+ out = _run(canvas=(320, 320))
+ assert {_delivered(f)[0] for f in out.frames} == {(320, 320)}
+
+
+def _wide_master(ratio: float) -> bytes:
+ """主体宽高比为 ratio 的母版(源画幅给足,别让主体被源边界裁掉)。"""
+ h = 60
+ img = Image.new("RGBA", (int(h * ratio) + 200, 200), (0, 0, 0, 0))
+ img.paste((200, 60, 60, 255), (20, 60, 20 + int(h * ratio), 60 + h))
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def test_precheck_and_output_share_the_same_canvas_geometry():
+ """入口预检必须按**出帧用的那个 canvas** 判,不能按方形判、按非方出。
+
+ 比例上限是从交付画布几何推出来的(REJECT_ASPECT*(cw/ch))。取一个夹在
+ "方形阈值"与"384×512 阈值"之间的母版:方形画布下该放行,窄高画布下该拒。
+ 两边不一致就说明预检和出帧用的不是同一套几何。
+ """
+ import pytest
+
+ from windup_ai_engine.master_check import REJECT_ASPECT, reject_aspect_for
+ from windup_ai_engine.ports import MasterRejectCode, MasterRejected
+
+ ratio = (REJECT_ASPECT + reject_aspect_for((384, 512))) / 2
+ master = _wide_master(ratio)
+ card = CharacterCard(name="wide", desc="wide creature")
+ action = ActionSpec(action=ActionType.WALK, n_frames=2)
+ gen = _make_generator()
+
+ gen.generate(card, action, master, _NullProgress(), canvas=(512, 512)) # 方形:放行
+
+ with pytest.raises(MasterRejected) as e:
+ gen.generate(card, action, master, _NullProgress(), canvas=(384, 512))
+ assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE
diff --git a/backend/tests/test_attack_archetypes.py b/backend/tests/test_attack_archetypes.py
new file mode 100644
index 00000000..4a75dc11
--- /dev/null
+++ b/backend/tests/test_attack_archetypes.py
@@ -0,0 +1,209 @@
+"""攻击提示词按运动拓扑分支 + 全动作统一构图约束。
+
+这一片锁的是 #195 的**形状层**残留:名词清干净了,`crescent arc` / `broad side` 这类
+形状短语仍在断言"手里有一件有宽面的长条物"。喂法杖 / 空手 / 四足角色时,模型调和图文
+矛盾最省力的解法就是凭空补出那件东西 —— 而帧数、时长、成色全部正常,没有一道会红。
+"""
+from __future__ import annotations
+
+import itertools
+
+import pytest
+from pydantic import ValidationError
+
+from windup_ai_engine.master_prep import ATTACK_MASTER_POSES
+from windup_ai_engine.prompt import (
+ build_attack_prompt,
+ build_custom_prompt,
+ build_idle_prompt,
+ build_jump_prompt,
+ build_walk_prompt,
+)
+from windup_ai_engine.prompt._framing import SINGLE_SUBJECT_FRAMING
+from windup_ai_engine.strategy.concrete import VideoFrameStrategy
+from windup_common.models import ActionSpec, ActionType, AttackArchetype, Facing
+
+FACINGS = (Facing.SIDE, Facing.FRONT)
+COMBOS = list(itertools.product(AttackArchetype, FACINGS))
+
+# 装备名词(#195)+ 形状短语(本片)。后者不点名任何物件,却同样断言了它的几何。
+_SHAPE_PRIORS = (
+ "sword", "blade", "staff", "wand", "weapon", "claw", "fist",
+ "broad side", "crescent", "long edge", "flat of the",
+)
+
+# 远程支不许写接触弧:写"挥过去 / 划过去"就是在逼模型造一个被打的对象。
+_CONTACT_ARC = ("sweep", "swing", "arc", "slash", "across the front", "connects with")
+
+# 整体位移词。i2v 强跟身体、弱跟持物,只写持物动作会让它自行漂移。
+_WHOLE_BODY = ("whole body", "torso", "hips", "lunge")
+
+
+def _hits(text: str, words) -> list[str]:
+ low = text.lower()
+ return [w for w in words if w in low]
+
+
+# ── ① 八种组合都取得到,且互不相同 ────────────────────────────────────────
+
+
+@pytest.mark.parametrize("archetype,facing", COMBOS)
+def test_every_archetype_and_facing_resolves_to_real_text(archetype, facing):
+ text = build_attack_prompt(facing=facing, archetype=archetype)
+ assert len(text) > 200, f"{archetype.value}.{facing.value} 短得不像正文:{text!r}"
+
+
+def test_the_eight_combinations_are_all_distinct():
+ """任意两支撞车 = 有一支没写、静默复用了别支的运动拓扑。"""
+ texts = [build_attack_prompt(facing=f, archetype=a) for a, f in COMBOS]
+ assert len(set(texts)) == len(COMBOS)
+
+
+def test_illegal_archetype_raises_instead_of_falling_back():
+ with pytest.raises(ValueError):
+ build_attack_prompt(facing=Facing.SIDE, archetype="sweeping")
+
+
+# ── ② 四支都不带装备形状先验 ─────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("archetype,facing", COMBOS)
+def test_no_branch_asserts_a_held_shape(archetype, facing):
+ named = _hits(build_attack_prompt(facing=facing, archetype=archetype), _SHAPE_PRIORS)
+ assert not named, f"{archetype.value}.{facing.value} 断言了持物形状: {named}"
+
+
+@pytest.mark.parametrize("archetype,facing", COMBOS)
+def test_the_striking_subject_is_a_body_part_not_an_arm(archetype, facing):
+ """主语写"手臂"= 断言角色有手臂,四足角色没有。"""
+ low = build_attack_prompt(facing=facing, archetype=archetype).lower()
+ assert "arm" not in low.replace("armour", "").replace("armor", ""), \
+ f"{archetype.value}.{facing.value} 把发力主语写成了手臂"
+
+
+@pytest.mark.parametrize("archetype,facing", COMBOS)
+def test_every_branch_moves_the_whole_body(archetype, facing):
+ text = build_attack_prompt(facing=facing, archetype=archetype)
+ assert _hits(text, _WHOLE_BODY), \
+ f"{archetype.value}.{facing.value} 只写了肢体动作,没写身体整体位移"
+
+
+@pytest.mark.parametrize("facing", FACINGS)
+def test_ranged_branch_describes_no_contact_arc(facing):
+ """远程支写"挥过去"= 逼模型在画面里造一个被打的对象。"""
+ text = build_attack_prompt(facing=facing, archetype=AttackArchetype.PROJECT)
+ assert not _hits(text, _CONTACT_ARC), f"project.{facing.value} 写了接触弧: {_hits(text, _CONTACT_ARC)}"
+
+
+def test_the_contact_arc_check_would_catch_a_real_swing():
+ """反向校准:这组词在近战支上确实会命中,否则上一条恒真、什么也没测。"""
+ swept = build_attack_prompt(facing=Facing.SIDE, archetype=AttackArchetype.SWEEP)
+ assert _hits(swept, _CONTACT_ARC)
+
+
+# ── ③ 默认支必须是 THRUST ────────────────────────────────────────────────
+
+
+def test_default_archetype_is_thrust_not_sweep():
+ """SWEEP 是唯一要求"手里有一件有宽面长条物"的一支;拿它当默认 = 对每个未知角色断言持械。"""
+ assert build_attack_prompt() == build_attack_prompt(archetype=AttackArchetype.THRUST)
+ assert build_attack_prompt() != build_attack_prompt(archetype=AttackArchetype.SWEEP)
+
+
+# ── ④ ActionSpec 契约:archetype 只属于 attack ────────────────────────────
+
+
+@pytest.mark.parametrize(
+ "action", [a for a in ActionType if a is not ActionType.ATTACK and a is not ActionType.CUSTOM]
+)
+def test_non_attack_actions_must_not_carry_an_archetype(action):
+ with pytest.raises(ValidationError, match="archetype"):
+ ActionSpec(action=action, archetype=AttackArchetype.SWEEP)
+
+
+def test_custom_action_must_not_carry_an_archetype_either():
+ with pytest.raises(ValidationError, match="archetype"):
+ ActionSpec(
+ action=ActionType.CUSTOM, custom_action="waves", cyclic=False,
+ archetype=AttackArchetype.SWEEP,
+ )
+
+
+def test_attack_takes_an_archetype_and_defaults_to_unspecified():
+ """不指定就是 None:缺省只由 build_attack_prompt 定义一次,契约层不兜第二份。"""
+ assert ActionSpec(action=ActionType.ATTACK).archetype is None
+ assert ActionSpec(
+ action=ActionType.ATTACK, archetype=AttackArchetype.LUNGE
+ ).archetype is AttackArchetype.LUNGE
+
+
+# ── ⑤ 派生层真的把 archetype 传下去了 ────────────────────────────────────
+
+
+@pytest.mark.parametrize("archetype", list(AttackArchetype))
+def test_strategy_builds_the_prompt_of_the_requested_archetype(archetype):
+ """契约字段填了却没人读,是本项目最典型的静默失败(见 ActionSpec.fps 那段)。"""
+ strat = VideoFrameStrategy(video=None, matte=None)
+ spec = ActionSpec(action=ActionType.ATTACK, archetype=archetype, facing=Facing.FRONT)
+ assert strat._build_prompt(spec) == build_attack_prompt(
+ facing=Facing.FRONT, archetype=archetype
+ )
+
+
+def test_strategy_without_an_archetype_falls_back_to_the_builder_default():
+ strat = VideoFrameStrategy(video=None, matte=None)
+ spec = ActionSpec(action=ActionType.ATTACK, facing=Facing.SIDE)
+ assert strat._build_prompt(spec) == build_attack_prompt(facing=Facing.SIDE)
+
+
+# ── ⑥ 统一构图后缀:五个动作都要带 ───────────────────────────────────────
+
+
+def _all_prompts() -> dict[str, str]:
+ out: dict[str, str] = {}
+ for facing in FACINGS:
+ out[f"walk.{facing.value}"] = build_walk_prompt(facing=facing)
+ out[f"jump.{facing.value}"] = build_jump_prompt(facing=facing)
+ out[f"idle.{facing.value}"] = build_idle_prompt(facing=facing)
+ out[f"custom.{facing.value}"] = build_custom_prompt(
+ "waves the right hand", facing=facing, cyclic=False
+ )
+ for archetype in AttackArchetype:
+ out[f"attack.{archetype.value}.{facing.value}"] = build_attack_prompt(
+ facing=facing, archetype=archetype
+ )
+ return out
+
+
+def test_every_action_prompt_carries_the_framing_clause():
+ """attack 是唯一没有构图约束的动作,而它恰恰有两处留白(母版姿态要留白 + 母版补边)。"""
+ missing = [k for k, v in _all_prompts().items() if SINGLE_SUBJECT_FRAMING not in v]
+ assert not missing, f"这些提示词没带构图约束: {missing}"
+
+
+def test_the_framing_clause_is_appended_by_code_not_copied_into_the_markdown():
+ """抄进每份 md 会各自漂移;md 正文里出现它就说明有人开始抄了。"""
+ from windup_ai_engine.prompt._md import load_doc
+
+ for doc in ("walk.md", "jump.md", "idle.md", "attack.md"):
+ for section, text in load_doc(doc).items():
+ assert "exactly one character" not in text.lower(), \
+ f"{doc} 的 {section} 把构图句抄进了 md"
+
+
+def test_the_framing_clause_counts_positively_instead_of_forbidding():
+ """该接口没有 negative_prompt:否定句里的名词会被 latch 进画面。"""
+ low = SINGLE_SUBJECT_FRAMING.lower()
+ assert "exactly one character" in low
+ hits = [w for w in (" not ", " no ", "n't", "without", "avoid", "never", "只") if w in low]
+ assert not hits, f"构图句写成了否定式: {hits}"
+
+
+# ── ⑦ 母版姿态的四支同样不带形状先验 ─────────────────────────────────────
+
+
+@pytest.mark.parametrize("archetype", list(AttackArchetype))
+def test_attack_master_poses_carry_no_shape_prior(archetype):
+ """母版是整条 i2v 链的身份来源,污染会一路带到所有动作。"""
+ named = _hits(ATTACK_MASTER_POSES[archetype], _SHAPE_PRIORS)
+ assert not named, f"attack.{archetype.value} 的母版姿态断言了持物形状: {named}"
diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py
new file mode 100644
index 00000000..1eb8cff9
--- /dev/null
+++ b/backend/tests/test_auth_api.py
@@ -0,0 +1,159 @@
+"""认证 API:覆盖 login / 验证码登录 / 改密 / 重置密码 / 改昵称调用链。"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from conftest import seed_invite_code
+
+from windup_app.server.user.model import User
+from windup_app.server.user.service import _hash_password, service
+
+
+@pytest.fixture()
+def mock_user_redis():
+ """把模块级 user service 的 Redis 换成 mock,避免打到真实实例。"""
+ redis_mock = MagicMock()
+ redis_mock.get.return_value = None
+ redis_mock.setex.return_value = True
+ redis_mock.delete.return_value = True
+ redis_mock.scan_iter.return_value = iter([])
+ previous = service._redis
+ service._redis = redis_mock
+ yield redis_mock
+ service._redis = previous
+
+
+@pytest.fixture()
+def seeded_user(db_session):
+ """与 auth_client token 对齐的用户(id=1)。"""
+ user = User(
+ id=1,
+ email="test@example.com",
+ password_hash=_hash_password("password123"),
+ nickname="旧昵称",
+ )
+ db_session.add(user)
+ db_session.flush()
+ return user
+
+
+def test_login_by_password_endpoint(client, seeded_user, mock_user_redis):
+ resp = client.post(
+ "/auth/login",
+ json={"email": "test@example.com", "password": "password123"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["message"] == "登录成功"
+ assert body["data"]["user"]["email"] == "test@example.com"
+ assert body["data"]["access_token"]
+ assert body["data"]["refresh_token"]
+
+
+def test_login_by_code_endpoint(client, seeded_user, mock_user_redis):
+ mock_user_redis.get.return_value = "123456"
+ resp = client.post(
+ "/auth/login-by-code",
+ json={"email": "test@example.com", "code": "123456"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["message"] == "登录成功"
+ assert body["data"]["user"]["email"] == "test@example.com"
+ assert body["data"]["access_token"]
+
+
+def test_change_password_endpoint(auth_client, seeded_user, mock_user_redis):
+ resp = auth_client.post(
+ "/auth/change-password",
+ json={"old_password": "password123", "new_password": "newpass123"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["message"] == "密码修改成功"
+
+
+def test_reset_password_endpoint(auth_client, seeded_user, mock_user_redis):
+ mock_user_redis.get.return_value = "654321"
+ resp = auth_client.post(
+ "/auth/reset-password",
+ json={
+ "email": "test@example.com",
+ "code": "654321",
+ "new_password": "resetpass1",
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["message"] == "密码重置成功"
+
+
+def test_register_endpoint_success(client, db_session, mock_user_redis):
+ seed_invite_code(db_session)
+ db_session.commit()
+ mock_user_redis.get.return_value = "123456"
+
+ resp = client.post(
+ "/auth/register",
+ json={
+ "email": "invitee@example.com",
+ "password": "password123",
+ "code": "123456",
+ "invite_code": "AB23CD45",
+ "nickname": "受邀用户",
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["message"] == "注册成功"
+ assert body["data"]["user"]["email"] == "invitee@example.com"
+ assert body["data"]["access_token"]
+
+
+def test_register_endpoint_success_without_invite_code(client, mock_user_redis):
+ mock_user_redis.get.return_value = "123456"
+ resp = client.post(
+ "/auth/register",
+ json={
+ "email": "open@example.com",
+ "password": "password123",
+ "code": "123456",
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["data"]["user"]["email"] == "open@example.com"
+ assert body["data"]["access_token"]
+
+
+def test_login_by_code_endpoint_creates_unknown_email(client, db_session, mock_user_redis):
+ mock_user_redis.get.return_value = "123456"
+ resp = client.post(
+ "/auth/login-by-code",
+ json={"email": "fresh@example.com", "code": "123456"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["data"]["user"]["email"] == "fresh@example.com"
+ assert (
+ db_session.query(User).filter(User.email == "fresh@example.com").one_or_none()
+ is not None
+ )
+
+
+def test_update_nickname_endpoint(auth_client, seeded_user, mock_user_redis):
+ resp = auth_client.patch("/auth/profile", json={"nickname": "新昵称"})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == 200
+ assert body["message"] == "昵称修改成功"
+ assert body["data"]["nickname"] == "新昵称"
+ assert body["data"]["email"] == "test@example.com"
diff --git a/backend/tests/test_auth_registration_closed.py b/backend/tests/test_auth_registration_closed.py
new file mode 100644
index 00000000..28fa99df
--- /dev/null
+++ b/backend/tests/test_auth_registration_closed.py
@@ -0,0 +1,25 @@
+"""无效邀请码不得建号。"""
+
+from windup_common.enums.biz_code import BizCode
+
+from windup_app.server.user.model import User
+
+
+def test_register_endpoint_rejects_invalid_invite_code(client, db_session):
+ resp = client.post(
+ "/auth/register",
+ json={
+ "email": "new@example.com",
+ "password": "password123",
+ "code": "123456",
+ "invite_code": "NOPE1234",
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == BizCode.BAD_REQUEST
+ assert body["message"] == "邀请码无效"
+ assert (
+ db_session.query(User).filter(User.email == "new@example.com").one_or_none()
+ is None
+ )
diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py
index 13500201..2ff59762 100644
--- a/backend/tests/test_character_api.py
+++ b/backend/tests/test_character_api.py
@@ -1,5 +1,26 @@
"""角色 CRUD API 集成测试。"""
+import pytest
+
+from windup_app.server.character.model import Character
+from windup_app.server.character.service import service as character_service
+from windup_common.enums.character import CharacterStatus
+
+
+class _FakeNamer:
+ def name_from_description(self, description: str) -> str:
+ return f"名:{description}"[:20]
+
+
+@pytest.fixture(autouse=True)
+def _inject_fake_character_namer():
+ original = character_service._namer
+ character_service._namer = _FakeNamer()
+ try:
+ yield
+ finally:
+ character_service._namer = original
+
def _create_project(auth_client, name: str = "默认项目") -> dict:
"""创建一个项目并返回响应 data。"""
@@ -24,6 +45,43 @@ def _payload(project_id: int, **overrides):
return base
+def _payload_with_frames(project_id: int, **overrides):
+ """构造包含真实帧的创建角色请求体。"""
+ base = {
+ "project_id": project_id,
+ "workflow_run_id": 1,
+ "name": "有帧角色",
+ "description": "包含真实帧",
+ "character_data": {
+ "outfits": [{
+ "id": "outfit-1",
+ "name": "默认造型",
+ "actions": [{
+ "id": "action-1",
+ "type": "idle",
+ "name": "待机",
+ "frame_count": 1,
+ "frames": [{"index": 0, "image_url": "https://example.com/frame.png"}],
+ }],
+ }],
+ },
+ }
+ base.update(overrides)
+ return base
+
+
+def test_character_model_defaults_to_draft(db_session):
+ """非 API 写入也不得把尚无真实动作帧的角色默认为已发布。"""
+ from conftest import insert_project
+
+ project = insert_project(db_session)
+ character = Character(project_id=project.id, workflow_run_id=999, character_data={})
+ db_session.add(character)
+ db_session.flush()
+
+ assert character.status == CharacterStatus.DRAFT
+
+
# -- POST /characters --------------------------------------------------------
@@ -44,7 +102,7 @@ def test_create_without_name(auth_client):
resp = auth_client.post("/characters", json=_payload(project["id"], name=None))
assert resp.json()["code"] == 200
- assert resp.json()["data"]["name"] is None
+ assert resp.json()["data"]["name"] == "名:主角"
def test_create_name_roundtrip(auth_client):
@@ -58,6 +116,21 @@ def test_create_name_roundtrip(auth_client):
assert resp.json()["data"]["name"] == "小精灵"
+def test_create_same_workflow_run_returns_existing_character(auth_client):
+ project = _create_project(auth_client)
+ payload = _payload(project["id"], workflow_run_id=42)
+
+ first = auth_client.post("/characters", json=payload).json()
+ second = auth_client.post("/characters", json=payload).json()
+ listed = auth_client.get("/characters", params={"project_id": project["id"]}).json()
+
+ assert first["code"] == 200
+ assert second["code"] == 200
+ assert second["data"]["id"] == first["data"]["id"]
+ assert listed["total"] == 1
+ assert [character["id"] for character in listed["data"]] == [first["data"]["id"]]
+
+
# -- 跨用户权限校验 -------------------------------------------------------------
@@ -70,6 +143,24 @@ def test_create_under_other_users_project_returns_404(auth_client, auth_client_b
assert resp.json()["message"] == "项目不存在"
+def test_create_same_workflow_run_under_another_project_returns_404(
+ auth_client, auth_client_b,
+):
+ project_a = _create_project(auth_client, "用户 A 项目")
+ project_b = _create_project(auth_client_b, "用户 B 项目")
+ created = auth_client.post(
+ "/characters", json=_payload(project_a["id"], workflow_run_id=42),
+ ).json()["data"]
+
+ resp = auth_client_b.post(
+ "/characters", json=_payload(project_b["id"], workflow_run_id=42),
+ )
+
+ assert resp.json()["code"] == 404
+ assert resp.json()["data"] is None
+ assert auth_client.get(f"/characters/{created['id']}").json()["code"] == 200
+
+
def test_list_other_users_project_characters_returns_404(auth_client, auth_client_b):
"""用户 B 不能列出用户 A 项目下的角色。"""
project = _create_project(auth_client)
@@ -120,3 +211,111 @@ def test_delete_other_users_character_returns_404(auth_client, auth_client_b):
assert resp.json()["code"] == 404
assert resp.json()["message"] == "角色不存在"
+
+
+# -- 角色发布状态过滤 ---------------------------------------------------------
+
+
+def test_create_character_without_frames_is_draft(auth_client):
+ """没有真实帧的角色应自动标记为草稿(status=0)。"""
+ project = _create_project(auth_client)
+ resp = auth_client.post("/characters", json=_payload(project["id"]))
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["status"] == 0
+
+
+def test_create_character_with_frames_is_published(auth_client):
+ """包含真实帧的角色应自动标记为已发布(status=1)。"""
+ project = _create_project(auth_client)
+ resp = auth_client.post("/characters", json=_payload_with_frames(project["id"]))
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["status"] == 1
+
+
+def test_list_characters_filter_by_status(auth_client):
+ """按 status 过滤角色列表。"""
+ project = _create_project(auth_client)
+ # 创建草稿角色
+ auth_client.post("/characters", json=_payload(project["id"], workflow_run_id=1))
+ # 创建已发布角色
+ auth_client.post("/characters", json=_payload_with_frames(project["id"], workflow_run_id=2))
+
+ # 查询已发布角色
+ resp = auth_client.get("/characters", params={"project_id": project["id"], "status": 1})
+ data = resp.json()
+ assert data["code"] == 200
+ assert data["total"] == 1
+ assert len(data["data"]) == 1
+ assert data["data"][0]["status"] == 1
+
+ # 查询草稿角色
+ resp = auth_client.get("/characters", params={"project_id": project["id"], "status": 0})
+ data = resp.json()
+ assert data["code"] == 200
+ assert data["total"] == 1
+ assert len(data["data"]) == 1
+ assert data["data"][0]["status"] == 0
+
+
+def test_list_characters_without_status_returns_all(auth_client):
+ """不传 status 参数时返回所有角色。"""
+ project = _create_project(auth_client)
+ auth_client.post("/characters", json=_payload(project["id"], workflow_run_id=1))
+ auth_client.post("/characters", json=_payload_with_frames(project["id"], workflow_run_id=2))
+
+ resp = auth_client.get("/characters", params={"project_id": project["id"]})
+ data = resp.json()
+ assert data["code"] == 200
+ assert data["total"] == 2
+ assert len(data["data"]) == 2
+
+
+def test_update_character_data_recalculates_status(auth_client):
+ """更新 character_data 后应自动重新计算 status。"""
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/characters", json=_payload(project["id"]),
+ ).json()["data"]
+
+ # 初始为草稿
+ assert created["status"] == 0
+
+ # 更新为包含帧的数据
+ resp = auth_client.patch(
+ f"/characters/{created['id']}",
+ json={
+ "character_data": {
+ "outfits": [{
+ "id": "outfit-1",
+ "name": "默认造型",
+ "actions": [{
+ "id": "action-1",
+ "type": "idle",
+ "name": "待机",
+ "frame_count": 1,
+ "frames": [{"index": 0, "image_url": "https://example.com/frame.png"}],
+ }],
+ }],
+ },
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["data"]["status"] == 1
+
+
+def test_update_character_with_null_character_data(auth_client):
+ """更新 character_data 为 null 时应返回 400 错误。"""
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/characters", json=_payload_with_frames(project["id"]),
+ ).json()["data"]
+
+ # 更新 character_data 为 null
+ resp = auth_client.patch(
+ f"/characters/{created['id']}",
+ json={"character_data": None},
+ )
+ assert resp.json()["code"] == 400
+ assert resp.json()["message"] == "character_data 不能为 null"
diff --git a/backend/tests/test_character_contract.py b/backend/tests/test_character_contract.py
new file mode 100644
index 00000000..0ebddf29
--- /dev/null
+++ b/backend/tests/test_character_contract.py
@@ -0,0 +1,382 @@
+"""跨层契约(windup_common.models.character)的类型约束。
+
+本文件锁的不是"字段叫什么名",而是**一类错误必须在构造 ActionSpec / CharacterCard 时就炸**:
+朝向拼错、帧数字段名打错、规格自相矛盾。这些错误以前一路放行到 i2v 调用之后才在画面上显形,
+一次误判的成本 = 一次付费视频生成 + 人肉看片。
+
+契约本身的断言在 feat/character-domain-models 那一片,只测 DTO、不 import 上层包。
+本分片引入 prompt 模块,于是把**实现侧**的配套断言补在这里:类型注解不是运行期约束,
+``build_*(facing="sidee")`` 必须当场炸。契约合法不代表实现读对了。
+"""
+from __future__ import annotations
+
+import inspect
+
+import pytest
+from pydantic import ValidationError
+
+from windup_ai_engine.master_prep import ATTACK_MASTER_POSES, MASTER_POSES
+from windup_ai_engine.prompt import (
+ build_attack_prompt,
+ build_idle_prompt,
+ build_jump_prompt,
+ build_walk_prompt,
+)
+from windup_ai_engine.strategy.concrete import VideoFrameStrategy
+from windup_common.models import (
+ DEFAULT_N_FRAMES,
+ ActionSpec,
+ ActionType,
+ AttackArchetype,
+ CharacterCard,
+ CharacterView,
+ Facing,
+ Stylize,
+)
+
+
+# ── A1 受限取值:枚举,不是裸 str ────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("bad", ["Side", "sidee", "SIDE", "left", "", None, 1])
+def test_facing_typo_is_rejected_at_construction(bad):
+ """朝向拼错必须当场炸。
+
+ 这条约束的分量:facing 决定用侧走词还是正面走词,而"提示词朝向必须与母版朝向一致"
+ 是三次实测挣得的硬前提(见 ai_engine.master_prep)。裸 str 时代 "Side" 一路放行,
+ 要等 i2v 出片、人眼看到角色转身才发现。
+ """
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.WALK, facing=bad)
+
+
+def test_legal_facing_string_is_coerced_to_enum_member():
+ """合法字符串仍可传(旧调用方零改动),但落到模型里是枚举成员。"""
+ spec = ActionSpec(action=ActionType.WALK, facing="front")
+ assert spec.facing is Facing.FRONT
+ assert ActionSpec(action=ActionType.WALK).facing is Facing.SIDE
+
+
+@pytest.mark.parametrize(
+ ("field", "bad", "good", "member"),
+ [
+ ("stylize", "pixels", "none", Stylize.NONE),
+ ],
+)
+def test_action_spec_restricted_fields_reject_typos(field, bad, good, member):
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.WALK, **{field: bad})
+ assert getattr(ActionSpec(action=ActionType.WALK, **{field: good}), field) is member
+
+
+def test_character_view_rejects_typos_and_matches_perspective_mapping():
+ """view 固定映射 perspective:1 side / 2 top-down / 3 isometric。
+
+ 字符串必须逐字一致,免得将来做 int↔str 映射时出现
+ topdown / top_down / top-down 三种写法。
+ """
+ assert {v.value for v in CharacterView} == {"side", "top-down", "isometric"}
+ with pytest.raises(ValidationError):
+ CharacterCard(name="n", desc="d", view="topdown") # 少了连字符
+ assert CharacterCard(name="n", desc="d", view="top-down").view is CharacterView.TOP_DOWN
+
+
+def test_character_card_default_view_is_a_legal_value():
+ """默认值必须落在自己的取值集合里。
+
+ 改枚举前的默认是 ``view = "pseudo-side"`` —— 它连自己行尾注释写的
+ "side / topdown / isometric" 都不在其中。任何 ``if card.view == "side"`` 的消费方
+ 对每一个默认构造的角色卡都会走错分支,且不会有任何报错。
+ """
+ assert CharacterCard(name="n", desc="d").view in set(CharacterView)
+
+
+def test_unknown_field_name_is_rejected_not_silently_dropped():
+ """字段名打错要炸。pydantic 默认 extra="ignore" 会静默吞掉。
+
+ ``n_frame``(少个 s)在 ignore 下的后果和 facing 拼错同级:不报错、不生效,
+ 调用方以为点了 16 帧,实际拿到默认 8 帧的成片。
+ """
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.WALK, n_frame=16)
+ with pytest.raises(ValidationError):
+ CharacterCard(name="n", desc="d", nmae="typo")
+
+
+# ── A1 实现侧:build_* 是普通函数,注解不构成运行期约束 ──────────────────────
+
+
+@pytest.mark.parametrize(
+ "build", [build_walk_prompt, build_jump_prompt, build_idle_prompt, build_attack_prompt]
+)
+def test_prompt_builders_reject_illegal_facing(build):
+ """直接调 build_*(facing="sidee") 仍要炸。
+
+ 类型注解不是运行期约束。若把校验删成 ``SIDE if facing == Facing.SIDE else FRONT``
+ 的二分,"sidee" 会静默落到 FRONT 模板 —— 正面走的提示词配侧面母版,
+ 模型靠转身调和矛盾,而调用方什么错都收不到。
+ """
+ with pytest.raises(ValueError):
+ build(facing="sidee")
+
+
+@pytest.mark.parametrize(
+ "build", [build_walk_prompt, build_jump_prompt, build_idle_prompt, build_attack_prompt]
+)
+def test_prompt_builders_accept_enum_and_legal_string_alike(build):
+ assert build(facing=Facing.FRONT) == build(facing="front")
+ assert build(facing=Facing.SIDE) == build(facing="side")
+
+
+def test_walk_prompt_picks_the_template_that_matches_facing():
+ """选模板的方向不能反 —— 只验"不炸"验不出模板接反。"""
+ side = build_walk_prompt(facing=Facing.SIDE)
+ front = build_walk_prompt(facing=Facing.FRONT)
+ assert side != front
+ # 不再断言 side == WALK_BODY_SIDE:那两个常量随 #233 删了,而且提示词搬进 md 之后
+ # 那条断言是循环论证(两边读同一份文件的同一节,必然相等)。要测的是**方向没接反**,
+ # 那就直接查朝向锁短语落在哪一条里。
+ assert "SIDE VIEW facing right" in side and "SIDE VIEW facing right" not in front
+ assert "FACING THE VIEWER" in front and "FACING THE VIEWER" not in side
+
+
+@pytest.mark.parametrize("build", [build_jump_prompt, build_idle_prompt, build_attack_prompt])
+def test_other_builders_also_switch_body_by_facing(build):
+ assert build(facing=Facing.SIDE) != build(facing=Facing.FRONT)
+ assert "FACING THE VIEWER" in build(facing=Facing.FRONT)
+
+
+# ── A1.5 提示词只描述动作,不断言角色装备(#195)────────────────────────────
+
+# 装备名词一旦进模板就是在断言该物件存在:母版没有斗篷,模型会为了满足文字凭空长一件,
+# 母版真有的特征反被挤掉(2026-08-11 拿一个完全无布料的刚性角色实跑复现)。
+# 这里连"角色确实持剑"的情形也一并禁掉——身份由母版承载,模板是所有角色共用的。
+_EQUIPMENT_NOUNS = (
+ "cape", "tabard", "cloak", "robe", "scarf",
+ "sword", "blade", "weapon", "shield", "axe", "spear",
+ "boot", "armor", "armour", "helmet", "gauntlet",
+)
+
+
+def _named_equipment(text: str) -> list[str]:
+ low = text.lower()
+ return [w for w in _EQUIPMENT_NOUNS if w in low]
+
+
+@pytest.mark.parametrize(
+ "build", [build_walk_prompt, build_jump_prompt, build_idle_prompt, build_attack_prompt]
+)
+@pytest.mark.parametrize("facing", [Facing.SIDE, Facing.FRONT])
+def test_prompt_names_no_equipment(build, facing):
+ """任一动作 × 任一朝向的正文里都不许出现装备名词。
+
+ 这条是 #195 的回归闸。**光验"参数能传"验不出这个 bug** —— 原先 garment/weapon
+ 确实是参数、确实能传,但零写入方,于是每个角色都吃到那个持剑披风原型的默认值。
+ """
+ named = _named_equipment(build(facing=facing))
+ assert not named, f"{build.__name__}({facing}) 断言了装备: {named}"
+
+
+def test_master_poses_name_no_equipment():
+ """母版姿势描述同样不许写装备 —— 母版是整条 i2v 链的身份来源,污染会传到所有动作。"""
+ poses = {**MASTER_POSES, **{a.value: t for a, t in ATTACK_MASTER_POSES.items()}}
+ for action, pose in poses.items():
+ named = _named_equipment(pose)
+ assert not named, f"母版姿态 {action!r} 断言了装备: {named}"
+
+
+@pytest.mark.parametrize(
+ "build", [build_walk_prompt, build_jump_prompt, build_idle_prompt, build_attack_prompt]
+)
+def test_prompt_builders_expose_facing_only(build):
+ """签名里只剩 facing。
+
+ 锁的是 #195 的**根因**而不只是症状:装备参数一旦以"有默认值的可选参数"形态存在,
+ 而调用侧(``strategy.concrete._build_prompt``)只传 facing,默认值就成了全体角色的
+ 实际取值。要按角色定制装备文字,得先有地方存它,那是角色卡契约的事;在这里留一个
+ 没人传的参数,只会让人以为该能力已经存在。
+
+ attack 多一个 ``archetype``:它选的是运动拓扑(身体怎么发力)、取值是枚举、且真有写入方
+ (``ActionSpec.archetype`` → ``_build_prompt``),与"没人传的装备参数"不是一类。
+ """
+ allowed = ["facing", "archetype"] if build is build_attack_prompt else ["facing"]
+ assert list(inspect.signature(build).parameters) == allowed
+
+
+def test_attack_archetype_is_an_enum_not_free_text():
+ """拼错的拓扑要当场炸,不能静默落到某一支 —— 理由同 facing 用枚举。"""
+ p = inspect.signature(build_attack_prompt).parameters["archetype"]
+ assert p.kind is inspect.Parameter.KEYWORD_ONLY, "archetype 必须是关键字参数,免得与 facing 传串位"
+ assert p.default is AttackArchetype.THRUST
+ with pytest.raises(ValueError):
+ build_attack_prompt(facing=Facing.SIDE, archetype="swep")
+
+
+def test_strategy_passes_only_facing_into_prompt_builders():
+ """派生层确实只按朝向选模板,没有第二条把角色装备塞进提示词的通路。"""
+ src = inspect.getsource(VideoFrameStrategy._build_prompt)
+ for kw in ("garment", "weapon", "feet"):
+ assert kw not in src, f"_build_prompt 又开始传 {kw} 了"
+
+
+# ── A2 n_frames 是显式字段,不再由 len(poses) 推导 ──────────────────────────
+
+
+def test_n_frames_is_explicit_and_needs_no_dummy_poses():
+ """要 16 帧就写 16 —— 不必编 16 条视频路线根本不读的姿势描述。"""
+ spec = ActionSpec(action=ActionType.WALK, n_frames=16)
+ assert spec.n_frames == 16
+ assert spec.poses == []
+
+
+def test_n_frames_defaults_to_the_contract_default():
+ assert ActionSpec(action=ActionType.WALK).n_frames == DEFAULT_N_FRAMES == 8
+
+
+def test_n_frames_falls_back_to_len_poses_for_old_callers():
+ """旧调用方只传 poses 时行为不变(兼容),包括显式传 None。"""
+ assert ActionSpec(action=ActionType.HIT, poses=["a", "b", "c"]).n_frames == 3
+ assert ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=None).n_frames == 2
+
+
+def test_n_frames_and_poses_may_agree():
+ assert ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=2).n_frames == 2
+
+
+def test_conflicting_n_frames_and_poses_raises_instead_of_picking_one():
+ """规格自相矛盾时炸掉,不猜。
+
+ common 层看不到 ROUTE_MATRIX(分层约束),判不出这条 spec 走视频还是逐帧,
+ 因此"哪个字段说了算"无从判定。猜一个的代价是静默出错帧数的成片。
+ """
+ with pytest.raises(ValidationError, match="n_frames"):
+ ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=16)
+
+
+@pytest.mark.parametrize("bad", [0, -1])
+def test_n_frames_must_be_at_least_one(bad):
+ """0 帧的 spec 不能进管线:付一次视频的钱、抽 0 帧、产出一个空动作。"""
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.WALK, n_frames=bad)
+
+
+def test_explicit_none_means_unspecified_with_or_without_poses():
+ """``n_frames=None`` 两条分支行为一致 —— 都当"没指定"。
+
+ 调用方常写 ``n_frames=payload.get("n_frames")``。修之前:有 poses 时 None 回退到
+ len(poses),没 poses 时 None 撞上 ``n_frames: int`` 直接 ValidationError ——
+ 同一个"未指定"在两种上下文里一个能用一个报错。
+ """
+ assert ActionSpec(action=ActionType.WALK, n_frames=None).n_frames == DEFAULT_N_FRAMES
+ assert ActionSpec(action=ActionType.HIT, poses=["a", "b"], n_frames=None).n_frames == 2
+
+
+def test_json_string_n_frames_agreeing_with_poses_is_not_a_conflict():
+ """JSON 入参里 n_frames 是字符串 "2"、poses 两条 —— 这是一致的,不该报打架。
+
+ 修之前 before 校验器在 pydantic 收敛类型之前直接 ``"2" != 2``,于是抛出自相矛盾的
+ 「n_frames=2 与 len(poses)=2 不一致」,把一次合法请求判成非法(2026-08-08 实测)。
+ """
+ spec = ActionSpec.model_validate({"action": "hit", "poses": ["a", "b"], "n_frames": "2"})
+ assert spec.n_frames == 2
+
+
+def test_json_string_n_frames_conflicting_with_poses_still_raises():
+ """收敛类型不等于放过打架 —— "16" vs 2 条 poses 仍要炸。"""
+ with pytest.raises(ValidationError, match="n_frames"):
+ ActionSpec.model_validate({"action": "hit", "poses": ["a", "b"], "n_frames": "16"})
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {"action": "walk", "n_frames": None}, # 走删键分支(_without)
+ {"action": "walk", "poses": ["a", "b"]}, # 走补键分支
+ {"action": "walk", "poses": ["a"], "n_frames": 1},
+ ],
+)
+def test_validator_does_not_mutate_the_callers_payload(payload):
+ """before 校验器拿到的是调用方那个 dict 本体,原地改它会污染调用方的数据。
+
+ 三个入参分别覆盖校验器的三条出口 —— 只测一条会漏:最初这里只传了 poses 那一条,
+ 于是"删键分支改成原地 pop"的变异全绿通过(2026-08-08 变异验证抓到)。
+ """
+ before = {k: (list(v) if isinstance(v, list) else v) for k, v in payload.items()}
+ ActionSpec.model_validate(payload)
+ assert payload == before
+
+
+# ── 取值域:实现里已有的下界写进契约,别让实现悄悄纠正入参 ────────────────────
+
+
+@pytest.mark.parametrize(
+ ("field", "bad"),
+ [
+ ("fps", 0), ("fps", -1), # 播放侧的除数,0 无合法语义
+ ("pixel_h", 0), # postprocess.to_pixel_art 对 <1 本就 raise
+ ("palette_size", 1), # quantize(colors=max(2, …)) 会把 1 静默抬成 2
+ ],
+)
+def test_numeric_fields_reject_values_the_implementation_would_silently_fix(field, bad):
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.WALK, **{field: bad})
+
+
+
+
+# ── A3 palette 已删除 ───────────────────────────────────────────────────────
+
+
+def test_character_card_has_no_palette_field():
+ """``palette: str`` 已删(2026-08-08)。
+
+ 删而不是"定清格式"的理由:真正锁色的色板由 postprocess.master_pixel_spec 从母版像素里
+ 量出来(ndarray → _snap_to_palette),角色卡上再挂一个自由 str 就是同一件事的第二真相源,
+ 而且是更弱的那个 —— 零消费方。调用方填 "#1a1a2e,#e94560" 期待锁色,管线照旧用母版色板,
+ 不报错也不生效,正是本项目最忌讳的"看起来成功的错结果"。
+ """
+ assert "palette" not in CharacterCard.model_fields
+
+
+def test_passing_palette_now_fails_loudly():
+ """删字段要让旧调用方听得见响 —— extra="forbid" 保证它不是被静默丢弃。"""
+ with pytest.raises(ValidationError):
+ CharacterCard(name="n", desc="d", palette="#1a1a2e,#e94560")
+
+
+# ── A4 fps 与 loop 已删除(2026-08-10,机器审 P2)─────────────────────────────
+
+
+def test_action_spec_has_no_fps_or_loop_field():
+ """两个字段都是"接了不履约"的入参,删而不是留着加注释。
+
+ - ``fps``:零写入方(编排层构造 ActionSpec 时从不传),而 postprocess.frame_durations
+ 按动作查表、根本不看它。留着的后果是同一段素材有两个互相矛盾的播放速度:
+ ``fps=20`` 宣称 50ms/帧,walk 实际给 125ms/帧。播放时序的唯一真相源是出参的
+ ``durations``。
+ - ``loop``:零消费方。闭环行为写死在 slicing.pick_cycle —— 循环类动作一律抽单周期
+ 闭环,传 pingpong / none 不改变任何产出。调用方能为一段往返动画付费、拿到一段
+ 线性循环,正是本项目最忌讳的"静默成功"。
+
+ 与 palette 那两条同一条理由:没有实现的取值等于死代码,它让调用方以为该能力存在。
+ """
+ assert "fps" not in ActionSpec.model_fields
+ assert "loop" not in ActionSpec.model_fields
+
+
+@pytest.mark.parametrize(("field", "value"), [("fps", 20), ("loop", "pingpong")])
+def test_passing_fps_or_loop_now_fails_loudly(field, value):
+ """删字段要让旧调用方听得见响 —— extra="forbid" 保证不是被静默丢弃。"""
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.WALK, **{field: value})
+
+
+def test_loop_mode_enum_is_gone_from_the_public_surface():
+ """枚举本身也要删:留着它,下一个人会以为只是暂时没接线而照着填。
+
+ 真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。
+ """
+ import windup_common.models as m
+
+ assert not hasattr(m, "LoopMode")
+ assert "LoopMode" not in m.__all__
diff --git a/backend/tests/test_character_namer.py b/backend/tests/test_character_namer.py
new file mode 100644
index 00000000..f16a10ac
--- /dev/null
+++ b/backend/tests/test_character_namer.py
@@ -0,0 +1,38 @@
+"""LangChain 角色起名器:注入假 chat model,不打真实 LLM。"""
+
+from types import SimpleNamespace
+
+from windup_ai_engine.impl.character_namer import LangChainCharacterNamer
+
+
+class _FakeChat:
+ def __init__(self, content: object, error: Exception | None = None) -> None:
+ self.content = content
+ self.error = error
+ self.messages = None
+
+ def invoke(self, messages):
+ self.messages = messages
+ if self.error is not None:
+ raise self.error
+ return SimpleNamespace(content=self.content)
+
+
+def test_namer_returns_cleaned_model_text():
+ chat = _FakeChat(' "赤发旅人" ')
+ namer = LangChainCharacterNamer(chat_model=chat)
+
+ assert namer.name_from_description("红发少年站在雾港") == "赤发旅人"
+ assert chat.messages is not None
+
+
+def test_namer_truncates_to_20_chars():
+ chat = _FakeChat("风" * 25)
+ namer = LangChainCharacterNamer(chat_model=chat)
+ assert namer.name_from_description("一段描述") == "风" * 20
+
+
+def test_namer_construction_does_not_touch_chat_provider():
+ """装配应用时不能因为没有 AI_API_KEY 就炸。"""
+ namer = LangChainCharacterNamer()
+ assert namer._model is None
diff --git a/backend/tests/test_character_naming.py b/backend/tests/test_character_naming.py
new file mode 100644
index 00000000..945f1180
--- /dev/null
+++ b/backend/tests/test_character_naming.py
@@ -0,0 +1,140 @@
+"""角色名称解析:用户输入优先,空名称才走 LLM / 兜底。"""
+
+import pytest
+
+from windup_app.server.character.naming import FALLBACK_NAME, resolve_character_name
+
+
+class _FakeNamer:
+ def __init__(self, result: str = "赤发旅人", error: Exception | None = None) -> None:
+ self.result = result
+ self.error = error
+ self.calls: list[str] = []
+
+ def name_from_description(self, description: str) -> str:
+ self.calls.append(description)
+ if self.error is not None:
+ raise self.error
+ return self.result
+
+
+def test_keeps_user_name_and_does_not_call_namer():
+ namer = _FakeNamer()
+ assert resolve_character_name(" 勇者 ", "一段很长的描述", namer) == "勇者"
+ assert namer.calls == []
+
+
+def test_trims_user_name_to_20_chars():
+ assert resolve_character_name("龙" * 25, None, _FakeNamer()) == "龙" * 20
+
+
+def test_blank_name_uses_namer_on_description():
+ namer = _FakeNamer("雾港少年")
+ assert resolve_character_name(" ", "红发少年站在雾港码头", namer) == "雾港少年"
+ assert namer.calls == ["红发少年站在雾港码头"]
+
+
+def test_namer_output_is_trimmed_to_20_chars():
+ namer = _FakeNamer("超" * 30)
+ assert resolve_character_name(None, "很长的描述", namer) == "超" * 20
+
+
+def test_namer_failure_falls_back_to_description():
+ namer = _FakeNamer(error=RuntimeError("timeout"))
+ assert resolve_character_name(None, "码头上的红发剑士在等船", namer) == "码头上的红发剑士在等船"
+
+
+def test_namer_failure_truncates_long_description():
+ namer = _FakeNamer(error=RuntimeError("timeout"))
+ description = "这是一段超过二十个字的角色描述用来兜底"
+ assert resolve_character_name(None, description, namer) == description[:20]
+
+
+def test_empty_name_and_description_use_fallback():
+ namer = _FakeNamer()
+ assert resolve_character_name(None, None, namer) == FALLBACK_NAME
+ assert namer.calls == []
+
+
+def test_empty_namer_result_falls_back_to_description():
+ namer = _FakeNamer(" ")
+ assert resolve_character_name(None, "银发法师", namer) == "银发法师"
+
+
+def test_service_create_skips_namer_when_workflow_run_exists(db_session):
+ from conftest import insert_project
+ from windup_app.server.character.service import SqlAlchemyCharacterService
+
+ project = insert_project(db_session)
+ namer = _FakeNamer("第一次")
+ service = SqlAlchemyCharacterService(namer=namer)
+ first = service.create_character(
+ db_session,
+ project_id=project.id,
+ workflow_run_id=77,
+ name=None,
+ description="红发少年",
+ character_data={},
+ )
+ namer.result = "第二次"
+ second = service.create_character(
+ db_session,
+ project_id=project.id,
+ workflow_run_id=77,
+ name=None,
+ description="红发少年",
+ character_data={},
+ )
+
+ assert second.id == first.id
+ assert second.name == "第一次"
+ assert namer.calls == ["红发少年"]
+
+
+def test_service_create_skips_namer_on_cross_project_workflow_run(db_session):
+ from sqlalchemy.exc import IntegrityError
+
+ from conftest import insert_project
+ from windup_app.server.character.service import SqlAlchemyCharacterService
+
+ first_project = insert_project(db_session, project_name="项目一")
+ second_project = insert_project(db_session, project_name="项目二")
+ namer = _FakeNamer("第一次")
+ service = SqlAlchemyCharacterService(namer=namer)
+ service.create_character(
+ db_session,
+ project_id=first_project.id,
+ workflow_run_id=88,
+ name=None,
+ description="红发少年",
+ character_data={},
+ )
+
+ with pytest.raises(IntegrityError):
+ service.create_character(
+ db_session,
+ project_id=second_project.id,
+ workflow_run_id=88,
+ name=None,
+ description="另一段描述",
+ character_data={},
+ )
+
+ assert namer.calls == ["红发少年"]
+
+
+def test_service_create_uses_namer_when_name_missing(db_session):
+ from conftest import insert_project
+ from windup_app.server.character.service import SqlAlchemyCharacterService
+
+ project = insert_project(db_session)
+ service = SqlAlchemyCharacterService(namer=_FakeNamer("雾港少年"))
+ character = service.create_character(
+ db_session,
+ project_id=project.id,
+ workflow_run_id=901,
+ name=None,
+ description="红发少年站在雾港码头",
+ character_data={},
+ )
+ assert character.name == "雾港少年"
diff --git a/backend/tests/test_chat_provider.py b/backend/tests/test_chat_provider.py
new file mode 100644
index 00000000..e5a8a91a
--- /dev/null
+++ b/backend/tests/test_chat_provider.py
@@ -0,0 +1,28 @@
+"""Chat provider:空凭据 / 空型号不得构造 ChatOpenAI。"""
+
+import pytest
+
+from windup_framework.config.provider import AIProviderSettings
+from windup_framework.providers.chat import create_chat_model
+
+
+def test_create_chat_model_rejects_missing_api_key():
+ config = AIProviderSettings(api_key="", chat_model="gpt-4o-mini", model="")
+ with pytest.raises(ValueError, match="AI_API_KEY"):
+ create_chat_model(config)
+
+
+def test_create_chat_model_rejects_empty_model():
+ config = AIProviderSettings(api_key="test-key", chat_model="", model="")
+ with pytest.raises(ValueError, match="AI_CHAT_MODEL"):
+ create_chat_model(config)
+
+
+def test_create_chat_model_prefers_chat_model_over_generic_model():
+ config = AIProviderSettings(
+ api_key="test-key",
+ chat_model="gpt-4o-mini",
+ model="should-not-use",
+ )
+ chat = create_chat_model(config)
+ assert chat.model_name == "gpt-4o-mini"
diff --git a/backend/tests/test_cors_preflight.py b/backend/tests/test_cors_preflight.py
index 1e2f6c79..0a415a0c 100644
--- a/backend/tests/test_cors_preflight.py
+++ b/backend/tests/test_cors_preflight.py
@@ -196,16 +196,49 @@ def test_custom_origin_not_allowed(self, client):
)
assert "access-control-allow-origin" not in resp.headers
- def test_vercel_preview_domain_allowed(self, client):
- """Vercel 预览域名应被正则匹配允许。"""
- vercel_origin = "https://my-app-abc123.vercel.app"
+ def test_vercel_preview_domain_not_allowed_by_default(self):
+ """默认不应信任任意 Vercel 预览域名。"""
+ with mock.patch.dict(os.environ):
+ os.environ.pop("WINDUP_CORS_ORIGIN_REGEX", None)
- resp = client.options(
- "/auth/login",
- headers={
- "Origin": vercel_origin,
- "Access-Control-Request-Method": "POST",
- },
+ from windup_app.bootstrap.app import create_app
+ from fastapi.testclient import TestClient
+
+ test_client = TestClient(create_app())
+ resp = test_client.options(
+ "/auth/login",
+ headers={
+ "Origin": "https://evil.vercel.app",
+ "Access-Control-Request-Method": "POST",
+ },
+ )
+
+ assert "access-control-allow-origin" not in resp.headers
+
+ def test_project_scoped_vercel_regex_allowed(self):
+ """部署方可显式允许自己项目的 Vercel 预览域名。"""
+ regex = r"https://windup-.*\.vercel\.app"
+ with mock.patch.dict(os.environ, {"WINDUP_CORS_ORIGIN_REGEX": regex}):
+ from windup_app.bootstrap.app import create_app
+ from fastapi.testclient import TestClient
+
+ test_client = TestClient(create_app())
+ allowed_resp = test_client.options(
+ "/auth/login",
+ headers={
+ "Origin": "https://windup-feature-123.vercel.app",
+ "Access-Control-Request-Method": "POST",
+ },
+ )
+ rejected_resp = test_client.options(
+ "/auth/login",
+ headers={
+ "Origin": "https://evil.vercel.app",
+ "Access-Control-Request-Method": "POST",
+ },
+ )
+
+ assert allowed_resp.headers.get("access-control-allow-origin") == (
+ "https://windup-feature-123.vercel.app"
)
- assert resp.status_code == 200
- assert resp.headers.get("access-control-allow-origin") == vercel_origin
+ assert "access-control-allow-origin" not in rejected_resp.headers
diff --git a/backend/tests/test_custom_action.py b/backend/tests/test_custom_action.py
new file mode 100644
index 00000000..b682d291
--- /dev/null
+++ b/backend/tests/test_custom_action.py
@@ -0,0 +1,363 @@
+"""自定义动作生成(#239)。
+
+这一片锁的核心不是"能跑通",而是三类**静默错误**:
+ ① 用户写的动作描述**没进提示词**。今天它进的是 `CharacterCard.desc`,而视频路线一个
+ card 字段都不读 —— 前端传了、后端收了、模型没看见,而帧数/时长/成色全部正常。
+ ② 循环性**被猜**。"挥手"被当成循环 → 末帧接回首帧抽搐,同样没有任何一道会红。
+ ③ 提示词骨架**被绕过**。若只把用户那句话丢给 i2v,会一次丢掉朝向锁、正向措辞、
+ #195 的装备存在无关句、以及一次性动作的"只做一次+终态保持"。
+"""
+from __future__ import annotations
+
+import io
+import threading
+import time
+
+import pytest
+from PIL import Image
+from pydantic import ValidationError
+
+from windup_ai_engine.prompt import MAX_ACTION_CHARS, build_custom_prompt
+from windup_ai_engine.strategy.base import ROUTE_MATRIX, is_cyclic
+from windup_ai_engine.strategy.concrete import VideoFrameStrategy
+from windup_common.models import ActionSpec, ActionType, CharacterCard, Facing, GenRoute, Stylize
+
+# 装备名词黑名单,与 #195 那组回归测试同源:模板里出现任何一个都是在断言该物件存在。
+_EQUIPMENT = (
+ "cape", "tabard", "cloak", "robe", "scarf",
+ "sword", "blade", "weapon", "shield", "axe", "spear",
+ "boot", "armor", "armour", "helmet", "gauntlet",
+)
+# 否定式:这个 i2v 接口没有 negative_prompt,负面名词会被 latch 进画面。
+_NEGATIONS = (" not ", " no ", "n't", "without", "avoid", "never")
+
+
+def _png(shift: int = 0) -> bytes:
+ """一张带主体的小 RGBA PNG。``shift`` 让相邻帧有位移,否则抽帧看到的是 N 张同一张图。"""
+ im = Image.new("RGBA", (64, 96), (0, 0, 0, 0))
+ for y in range(20, 80):
+ for x in range(24 + shift, 40 + shift):
+ im.putpixel((x, y), (200, 60, 60, 255))
+ buf = io.BytesIO()
+ im.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _NullProgress:
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ pass
+
+
+def _spec(action: str = "waves the right hand above the head", *, cyclic: bool = False, **kw):
+ kw.setdefault("n_frames", 8)
+ kw.setdefault("stylize", Stylize.NONE)
+ return ActionSpec(action=ActionType.CUSTOM, custom_action=action, cyclic=cyclic, **kw)
+
+
+# ── ① 契约:custom 必须自带动作描述与循环性 ────────────────────────────────
+
+
+def test_custom_without_cyclic_is_rejected_at_construction():
+ """不给循环性就炸,**不给默认值**。"""
+ with pytest.raises(ValidationError, match="cyclic"):
+ ActionSpec(action=ActionType.CUSTOM, custom_action="挥手")
+
+
+def test_custom_without_description_is_rejected():
+ with pytest.raises(ValidationError, match="custom_action"):
+ ActionSpec(action=ActionType.CUSTOM, cyclic=False)
+
+
+@pytest.mark.parametrize("blank", ["", " ", "\n"])
+def test_blank_description_is_rejected(blank):
+ """空白描述不算给了 —— 否则等于付一次 i2v 的钱拿一段站着不动的视频。"""
+ with pytest.raises(ValidationError):
+ ActionSpec(action=ActionType.CUSTOM, custom_action=blank, cyclic=False)
+
+
+@pytest.mark.parametrize("field", ["custom_action", "cyclic"])
+def test_non_custom_actions_must_not_carry_custom_fields(field):
+ """给 walk 传 cyclic 要炸,不能静默忽略。"""
+ val = "挥手" if field == "custom_action" else True
+ with pytest.raises(ValidationError, match=field):
+ ActionSpec(action=ActionType.WALK, **{field: val})
+
+
+def test_custom_is_routed_to_video():
+ assert ROUTE_MATRIX[ActionType.CUSTOM] is GenRoute.VIDEO_I2V
+
+
+# ── ② 循环性:显式声明真的被用上 ───────────────────────────────────────────
+
+
+def test_is_cyclic_follows_the_explicit_flag_for_custom():
+ """custom 的循环性来自入参,不来自 CYCLIC_ACTIONS 那张表。"""
+ assert is_cyclic(_spec(cyclic=True)) is True
+ assert is_cyclic(_spec(cyclic=False)) is False
+
+
+def test_is_cyclic_still_reads_the_table_for_fixed_actions():
+ assert is_cyclic(ActionSpec(action=ActionType.WALK)) is True
+ assert is_cyclic(ActionSpec(action=ActionType.ATTACK)) is False
+
+
+def _offline_strategy(monkeypatch, spy: list[str]):
+ """离线 VideoFrameStrategy:抽帧被顶替,不联网不花钱;记下送进 i2v 的提示词。"""
+ dense = [Image.open(io.BytesIO(_png(i % 6))).convert("RGBA") for i in range(24)]
+ monkeypatch.setattr(
+ "windup_ai_engine.strategy.concrete.extract_all_frames_bytes",
+ lambda video, cap=150: dense,
+ )
+
+ class _SpyVideo:
+ def i2v(self, first_frame, prompt, seconds=5, size="1280x720"):
+ spy.append(prompt)
+ return b"fake-mp4"
+
+ class _Matte:
+ def cutout(self, frame):
+ return frame
+
+ return VideoFrameStrategy(_SpyVideo(), _Matte())
+
+
+def test_cyclic_flag_switches_the_slicing_mode(monkeypatch):
+ """loop=true 走单周期闭环、loop=false 走裁区间 —— 两条分支的进度文案不同。"""
+ seen: list[str] = []
+
+ class _Spy:
+ def step(self, stage, i, total, note=""):
+ seen.append(note)
+
+ spy: list[str] = []
+ strat = _offline_strategy(monkeypatch, spy)
+ card = CharacterCard(name="t", desc="t")
+
+ strat.derive(card, _spec(cyclic=True), _png(), _Spy())
+ assert any("无缝 loop" in n for n in seen), seen
+
+ seen.clear()
+ strat.derive(card, _spec(cyclic=False), _png(), _Spy())
+ assert any("不闭环" in n for n in seen), seen
+
+
+# ── ③ 用户描述必须真的进提示词(今天它进的是没人读的 card.desc)──────────────
+
+
+def test_user_description_actually_reaches_the_i2v_prompt(monkeypatch):
+ """这条是 #239 的核心缺口。"""
+ spy: list[str] = []
+ strat = _offline_strategy(monkeypatch, spy)
+ strat.derive(
+ CharacterCard(name="t", desc="这里写什么都不该影响产出"),
+ _spec("spins once on the left heel with both arms out"),
+ _png(), _NullProgress(),
+ )
+ assert spy, "没抓到送进 i2v 的提示词"
+ assert "spins once on the left heel" in spy[0], spy[0]
+
+
+def test_card_desc_still_does_not_leak_into_the_prompt(monkeypatch):
+ """反向:card.desc 不该进提示词。身份由母版承载,再写一遍会和母版打架。"""
+ spy: list[str] = []
+ strat = _offline_strategy(monkeypatch, spy)
+ strat.derive(
+ CharacterCard(name="t", desc="ZZQUIRKYSENTINEL"),
+ _spec("waves"), _png(), _NullProgress(),
+ )
+ assert "ZZQUIRKYSENTINEL" not in spy[0]
+
+
+# ── ④ 骨架不能被绕过 ─────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("facing", [Facing.SIDE, Facing.FRONT])
+@pytest.mark.parametrize("cyclic", [True, False])
+def test_scaffolding_survives_any_user_text(facing, cyclic):
+ """无论用户写什么,四项锁都必须在。"""
+ p = build_custom_prompt("挥手 and also wears a huge cape with a sword",
+ facing=facing, cyclic=cyclic)
+ low = p.lower()
+ # 朝向锁
+ if facing is Facing.SIDE:
+ assert "side view facing right" in low
+ else:
+ assert "facing the viewer" in low
+ # 存在无关的衣饰/手持物保持句(#195)
+ assert "whatever the character already wears" in low
+ assert "anything held in the hands" in low
+ # 循环性尾句
+ assert ("repeating cycle" in low) if cyclic else ("ONCE" in p)
+
+
+def test_scaffolding_never_asserts_equipment_even_if_the_user_does():
+ """用户在描述里写了斗篷与剑,**骨架自己**仍不得断言装备。"""
+ p = build_custom_prompt("waves the right hand", facing=Facing.SIDE, cyclic=False)
+ named = [w for w in _EQUIPMENT if w in p.lower()]
+ assert not named, f"骨架里出现了装备名词: {named}"
+
+
+def test_scaffolding_uses_only_positive_wording():
+ """这个 i2v 接口没有 negative_prompt,否定式会被 latch 进画面。"""
+ p = build_custom_prompt("waves the right hand", facing=Facing.SIDE, cyclic=False).lower()
+ hits = [w for w in _NEGATIONS if w in p]
+ assert not hits, f"骨架里出现否定式: {hits}"
+
+
+def test_oneshot_says_once_and_holds_the_end_pose():
+ """不写"只做一次 + 终态保持",模型会在 5 秒内复读第二次。"""
+ p = build_custom_prompt("swings the right arm down", facing=Facing.SIDE, cyclic=False)
+ assert "ONCE" in p
+ assert "holds the final pose" in p
+
+
+def test_empty_and_overlong_descriptions_are_rejected():
+ with pytest.raises(ValueError, match="不能为空"):
+ build_custom_prompt(" ", facing=Facing.SIDE, cyclic=False)
+ with pytest.raises(ValueError, match="超过上限"):
+ build_custom_prompt("x" * (MAX_ACTION_CHARS + 1), facing=Facing.SIDE, cyclic=False)
+
+
+def test_illegal_facing_raises_instead_of_falling_back():
+ """朝向拼错要炸,别静默落到某一支(理由同 prompt.walk)。"""
+ with pytest.raises(ValueError):
+ build_custom_prompt("waves", facing="sidee", cyclic=False)
+
+
+# ── ⑤ 视频模型可选 ───────────────────────────────────────────────────────
+
+
+def test_only_the_opened_models_are_accepted():
+ from windup_app.server.orchestrator.executor import (
+ ALLOWED_VIDEO_MODELS,
+ _resolve_video_model,
+ )
+
+ # veo3.1 不在表里:它走 Fal 队列协议(Authorization: Key + 公网图 URL),而
+ # SufyVideoProvider 走 OpenAI 风格 /videos + Bearer + base64。列进去 = 看起来能选、
+ # 点了必然产生一个用不了的付费任务。
+ assert set(ALLOWED_VIDEO_MODELS) == {"kling-v2-5-turbo", "kling-v2-6"}
+ assert "veo3.1" not in ALLOWED_VIDEO_MODELS
+ for name in ALLOWED_VIDEO_MODELS:
+ assert _resolve_video_model(name) == name
+ assert _resolve_video_model(None) is None, "None = 用部署默认值"
+
+
+def test_unknown_model_fails_at_entry_not_at_the_paid_call():
+ """非法模型名在入口炸。"""
+ from windup_app.server.orchestrator.executor import _resolve_video_model
+
+ with pytest.raises(ValueError) as e:
+ _resolve_video_model("sora-2")
+ assert "kling-v2-5-turbo" in str(e.value), "报错要带上可选值,否则调用方无从改"
+
+
+def test_generator_is_bucketed_by_video_model():
+ """按模型分桶,否则第一个请求指定 veo3.1 之后所有请求都沿用它。"""
+ from windup_app.server.orchestrator.executor import ActionTaskExecutor
+
+ ex = ActionTaskExecutor()
+ a = ex._get_generator("kling-v2-6")
+ b = ex._get_generator("veo3.1")
+ assert a is not b, "两个模型拿到了同一个 generator"
+ assert ex._get_generator("kling-v2-6") is a, "同一模型该复用"
+
+
+def test_concurrent_first_requests_build_one_shared_provider_set(monkeypatch):
+ """并发首请求只装一份共用 provider。
+
+ 执行器是进程级单例、每个请求起一个线程,check-and-insert 不加锁时每个线程都会各装
+ 一套;而每个抠图实例会各自惰性加载一份 ONNX 会话,重复的代价落在内存与加载耗时上。
+ """
+ from windup_framework import providers
+
+ from windup_app.server.orchestrator.executor import ActionTaskExecutor
+
+ built: list[str] = []
+ tally = threading.Lock()
+
+ def _counting(name: str):
+ def _factory(*_args, **_kwargs):
+ with tally:
+ built.append(name)
+ time.sleep(0.02) # 放大 check-and-insert 的窗口:不加锁时必然重复装配
+ return object()
+ return _factory
+
+ monkeypatch.setattr(providers, "OnnxU2NetMatteProvider", _counting("matte"))
+ monkeypatch.setattr(providers, "SufyImageProvider", _counting("image"))
+ monkeypatch.setattr(providers, "SufyVideoProvider", _counting("video"))
+
+ ex = ActionTaskExecutor()
+ models = ["kling-v2-5-turbo", "kling-v2-6"] * 3
+ start = threading.Barrier(len(models))
+ got: dict[int, object] = {}
+
+ def _ask(i: int) -> None:
+ start.wait(timeout=5)
+ gen = ex._get_generator(models[i])
+ with tally:
+ got[i] = gen
+
+ threads = [threading.Thread(target=_ask, args=(i,)) for i in range(len(models))]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout=10)
+
+ assert not any(t.is_alive() for t in threads), "有线程没跑完,装配路径可能卡在锁上"
+ assert built.count("matte") == 1, f"抠图 provider 装了 {built.count('matte')} 次,该只装一次"
+ assert built.count("image") == 1, f"图生图 provider 装了 {built.count('image')} 次"
+ assert built.count("video") == 2, "视频 provider 随模型变,两个模型该各一份"
+ for i, model in enumerate(models):
+ assert got[i] is ex._by_model[model], "同一模型的并发请求该拿到同一个 generator"
+
+
+# ── ⑥ 骨架不得夹带姿态前提(游泳/潜水/飞行都不着地不直立)─────────────────────
+
+# "着地 / 直立 / 双足"对 walk/idle/attack/jump 成立,对任意动作不成立。骨架里写了它们,
+# 遇到游泳就与用户的动作直接矛盾,而文字与动作矛盾时模型会自己找辙调和。
+_POSTURE_ASSUMPTIONS = (
+ "on the ground", "standing", "upright", "both feet", "feet stay",
+ "legs clearly", "upper body stays calm", "on the spot", "planted",
+)
+
+
+@pytest.mark.parametrize("cyclic", [True, False])
+@pytest.mark.parametrize("facing", [Facing.SIDE, Facing.FRONT])
+def test_scaffolding_carries_no_posture_assumptions(facing, cyclic):
+ """骨架只许断言对任何动作都成立的东西。"""
+ p = build_custom_prompt("swims forward with alternating overarm strokes",
+ facing=facing, cyclic=cyclic).lower()
+ hits = [w for w in _POSTURE_ASSUMPTIONS if w in p]
+ assert not hits, f"骨架夹带了姿态前提: {hits}"
+
+
+def test_oneshot_tail_does_not_dictate_what_the_final_pose_is():
+ """只要求保持终态,不规定终态是什么 —— 潜水结束不该被掰回站姿。"""
+ p = build_custom_prompt("dives down head first", facing=Facing.SIDE, cyclic=False)
+ assert "holds the final pose" in p
+ assert "standing" not in p.lower() and "upright" not in p.lower()
+
+
+def test_cyclic_tail_still_keeps_the_character_in_place():
+ """去掉"在地面上"之后,**不整体位移**这条仍要在 —— 位移交引擎当 root motion。"""
+ p = build_custom_prompt("swims forward", facing=Facing.SIDE, cyclic=True).lower()
+ assert "same spot" in p
+
+
+# ── ⑦ loop 缺失时的安全默认 ──────────────────────────────────────────────
+
+
+def test_missing_loop_falls_back_to_oneshot_instead_of_failing():
+ """缺 loop 时兜成一次性,不是硬失败。"""
+ from windup_app.server.orchestrator.model import ActionType as ApiActionType
+ from windup_app.server.orchestrator.model import CharacterActionInput
+
+ inp = CharacterActionInput(
+ character_id=1, action_type=ApiActionType.CUSTOM, custom_prompt="waves", loop=None
+ )
+ assert inp.loop is None, "DTO 层不该替调用方填默认值,默认发生在编排层"
+ # 编排层把 None 兜成一次性 —— 与 executor 里那段注释同一口径
+ cyclic = False if inp.loop is None else bool(inp.loop)
+ assert cyclic is False
diff --git a/backend/tests/test_env_example_keys_are_live.py b/backend/tests/test_env_example_keys_are_live.py
new file mode 100644
index 00000000..0def2f8a
--- /dev/null
+++ b/backend/tests/test_env_example_keys_are_live.py
@@ -0,0 +1,76 @@
+"""`.env.example` 里的键必须真的被读到。
+
+填了、不报错、也不生效的配置键是本仓反复清理的那一类问题(``ActionSpec.fps`` /
+``CharacterCard.palette``)。区别在于配置模板错了会**每个新部署重犯一次**,而且只有
+去问运行中的进程才发现 —— 照模板填 ``LLM_IMAGE_MODEL_ID=gemini-3.0-pro-image-preview``
+的部署,实际跑的是 ``AIProviderSettings.image_model`` 的默认值。Refs 1024XEngineer/Windup#288。
+"""
+from __future__ import annotations
+
+import pathlib
+import re
+
+import pytest
+
+_ENV_EXAMPLE = pathlib.Path(__file__).resolve().parents[2] / ".env.example"
+
+# 非 pydantic-settings 消费的键:由 docker-compose / 部署脚本直接读,不走配置类。
+_INFRA_KEYS = frozenset({
+ "WINDUP_HOST", "WINDUP_PORT", "WINDUP_CORS_ORIGINS", "WINDUP_CORS_ORIGIN_REGEX",
+ "POSTGRES_DATA_DIR", "REDIS_DATA_DIR", "POSTGRES_EXTERNAL_PORT",
+ "SERPAPI_API_KEY", "VITE_API_BASE_URL",
+})
+
+
+def _example_keys() -> set[str]:
+ if not _ENV_EXAMPLE.is_file():
+ pytest.skip(f"没有 {_ENV_EXAMPLE}")
+ return {
+ m.group(1)
+ for line in _ENV_EXAMPLE.read_text(encoding="utf-8").splitlines()
+ if (m := re.match(r"^([A-Z][A-Z0-9_]*)=", line.strip()))
+ }
+
+
+def _settings_classes():
+ """遍历 config 包的每个子模块 —— 包的 __init__ 未必把配置类都再导出一遍。"""
+ import importlib
+ import pkgutil
+
+ from pydantic_settings import BaseSettings
+
+ import windup_framework.config as cfg
+
+ out = []
+ for mod in pkgutil.iter_modules(cfg.__path__):
+ m = importlib.import_module(f"{cfg.__name__}.{mod.name}")
+ for name in dir(m):
+ obj = getattr(m, name)
+ if (isinstance(obj, type) and issubclass(obj, BaseSettings)
+ and obj is not BaseSettings and obj not in out):
+ out.append(obj)
+ return out
+
+
+def _live_keys() -> set[str]:
+ """所有配置类按各自 env_prefix 展开出来的、真正会被读的环境变量名。"""
+ keys = set()
+ for cls in _settings_classes():
+ prefix = cls.model_config.get("env_prefix", "")
+ keys |= {f"{prefix}{f}".upper() for f in cls.model_fields}
+ return keys
+
+
+def test_settings_classes_are_discoverable():
+ """先验仪器:一个配置类都没找到的话,下面那条会空跑成绿的。"""
+ assert len(_settings_classes()) >= 5
+
+
+def test_every_example_key_is_actually_read():
+ live = _live_keys()
+ dead = sorted(k for k in _example_keys() if k not in live and k not in _INFRA_KEYS)
+ assert not dead, (
+ f"这些键在 .env.example 里,但没有任何配置类会读:{dead}。"
+ "填了不生效比不填更糟——部署方以为配置生效了。"
+ "确认前缀与对应 BaseSettings 的 env_prefix 一致。"
+ )
diff --git a/backend/tests/test_extract_streaming.py b/backend/tests/test_extract_streaming.py
new file mode 100644
index 00000000..715bd45d
--- /dev/null
+++ b/backend/tests/test_extract_streaming.py
@@ -0,0 +1,148 @@
+"""抽帧必须流式,不能把整段视频 materialize 出来(2026-08-10 机器审 P2)。
+
+原先 ``iio.imread`` 一次性读出 ``(T, H, W, C)``。实测 121 帧 720p 的真实 i2v 视频,
+进程 RSS 峰值 488 MiB,而抽 16 帧只需要其中 16 帧;并发 worker 叠加时这是实打实的内存墙。
+改成 ``imiter`` 后同一段视频 126 MiB(降 74%);抽 8 帧降 79%。
+
+如实说明降幅的边界:``extract_all_frames_bytes(cap=150)``(周期检测用)会保留全部 121 帧,
+只降 42% —— 省掉的是那个完整 ndarray,保留帧本身该占的内存还在。
+"""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from windup_ai_engine.slicing.extract import (
+ _extract_frames,
+ _frame_count,
+ _uniform_indices,
+ extract_frames_bytes,
+)
+
+
+class _WentThroughImread(BaseException):
+ """故意继承 BaseException 而不是 Exception —— 见下面用例的 docstring。"""
+
+
+def _forbidden(*a, **k):
+ raise _WentThroughImread("走了 imread:整段视频被 materialize 了")
+
+
+@pytest.fixture(scope="module")
+def video(tmp_path_factory) -> str:
+ """20 帧的合成视频,每帧一个可辨认的灰度值,用来验"抽到的是哪几帧"。"""
+ iio = pytest.importorskip("imageio.v3")
+ path = tmp_path_factory.mktemp("v") / "ramp.mp4"
+ # 每帧填 i*12,H.264 有损但相邻帧差 12 足以区分;尺寸取 16 的倍数避开编码器 padding。
+ frames = [np.full((64, 64, 3), i * 12, dtype=np.uint8) for i in range(20)]
+ iio.imwrite(path, np.stack(frames), plugin="pyav", codec="libx264")
+ return str(path)
+
+
+# ── 下标计算 ────────────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize(("total", "n", "expect"), [
+ (20, 1, [0]), # n=1 不能撞 /(n-1) 除零
+ (1, 8, [0]), # 要的比有的多:给全部,不重复
+ (1, 1, [0]),
+ (20, 20, list(range(20))),
+ (20, 2, [0, 19]), # 必须含首尾
+])
+def test_uniform_indices_covers_the_boundaries(total, n, expect):
+ assert _uniform_indices(total, n) == expect
+
+
+def test_uniform_indices_never_exceeds_total():
+ for total in (1, 3, 20, 121):
+ for n in (1, 5, 16, 150):
+ idx = _uniform_indices(total, n)
+ assert len(idx) == min(n, total)
+ assert len(set(idx)) == len(idx), "下标不该重复——重复等于同一帧算两帧"
+ assert idx == sorted(idx) and idx[0] == 0
+ # n=1 取首帧(与改造前的实现一致,已在 14 段真实视频上验过逐像素相同);
+ # 关键姿势的选择归 pick_oneshot,不由抽帧层猜。
+ assert len(idx) == 1 or idx[-1] == total - 1
+
+
+# ── 流式:不许再整段读入 ─────────────────────────────────────────────────────
+
+
+def test_extraction_does_not_materialise_the_whole_video(video, monkeypatch):
+ """把 ``imread`` 换成炸弹:仍能抽帧,才说明走的是逐帧迭代。
+
+ 这是本文件的核心断言 —— 改回 ``iio.imread`` 会让它变红(变异测试确认)。
+
+ 炸弹必须抛 ``BaseException`` 的子类:``_extract_frames`` 用 ``except Exception``
+ 兜底到 ffmpeg,抛 ``AssertionError`` 会被它吞掉、静默走 ffmpeg 分支产出正确帧数,
+ 这条用例于是变成摆设 —— 2026-08-10 变异测试逮到,原版正是这么写的。
+ """
+ import imageio.v3 as iio
+
+ monkeypatch.setattr(iio, "imread", _forbidden)
+ frames = _extract_frames(video, 5)
+ assert len(frames) == 5
+ assert all(isinstance(f, Image.Image) and f.mode == "RGBA" for f in frames)
+
+
+def test_extracted_frames_are_the_uniformly_spaced_ones(video):
+ """抽的是首尾与均匀分布的那几帧,不是前 n 帧。
+
+ 合成视频每帧灰度递增,所以取出来的灰度序列必须是递增且跨越全程的。
+ """
+ frames = _extract_frames(video, 5)
+ greys = [int(np.asarray(f.convert("L")).mean()) for f in frames]
+ assert greys == sorted(greys), f"帧序错乱:{greys}"
+ assert greys[0] < 30, f"首帧不是第 0 帧(灰度 {greys[0]})"
+ assert greys[-1] > 200, f"末帧不是最后一帧(灰度 {greys[-1]})"
+
+
+def test_asking_for_more_frames_than_the_video_has_returns_all(video):
+ """要 150 帧、视频只有 20 帧:给 20 帧,不静默补帧也不报错。"""
+ assert len(_extract_frames(video, 150)) == 20
+
+
+def test_bytes_entry_point_streams_too(video, monkeypatch):
+ """公开入口是 bytes 版,它也必须走流式(它只是多包了一层临时文件)。"""
+ import imageio.v3 as iio
+
+ monkeypatch.setattr(iio, "imread", _forbidden)
+ with open(video, "rb") as f:
+ assert len(extract_frames_bytes(f.read(), 4)) == 4
+
+
+def test_bundled_ffmpeg_is_used_when_pyav_cannot_decode(video, monkeypatch):
+ import imageio.v3 as iio
+ from imageio_ffmpeg import get_ffmpeg_exe
+
+ monkeypatch.setattr(iio, "improps", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no pyav")))
+ monkeypatch.setattr(iio, "imiter", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no pyav")))
+
+ frames = _extract_frames(video, 4)
+
+ assert get_ffmpeg_exe()
+ assert len(frames) == 4
+
+
+# ── 帧数元数据不可信时的兜底 ─────────────────────────────────────────────────
+
+
+def test_frame_count_falls_back_to_counting_when_metadata_is_useless(video, monkeypatch):
+ """容器元数据在 14 段真实视频上都准,但不同编码的 n_frames 并非都可靠。
+
+ 元数据给 0 / None / 抛错时必须退回逐帧计数——按错的帧数算下标会抽出错位的帧,
+ 那是"看起来成功"的失败(帧数对、内容错)。
+ """
+ import imageio.v3 as iio
+
+ assert _frame_count(video) == 20
+
+ class _Props:
+ shape = (0, 64, 64, 3)
+
+ monkeypatch.setattr(iio, "improps", lambda *a, **k: _Props())
+ assert _frame_count(video) == 20, "元数据报 0 帧时没退回计数"
+
+ monkeypatch.setattr(iio, "improps", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no")))
+ assert _frame_count(video) == 20, "元数据抛错时没退回计数"
diff --git a/backend/tests/test_frame_timing_and_master_prep.py b/backend/tests/test_frame_timing_and_master_prep.py
new file mode 100644
index 00000000..257ece24
--- /dev/null
+++ b/backend/tests/test_frame_timing_and_master_prep.py
@@ -0,0 +1,136 @@
+"""逐帧时长与母版预处理 —— 两者都在每次生成的主路径上,此前无直接覆盖。
+
+`frame_durations` 参与每一次出参构造;`prepare_master` 参与每一次 jump / attack 生成。
+纯计算,无需联网。
+"""
+from __future__ import annotations
+
+import io
+
+import pytest
+from PIL import Image
+
+from windup_ai_engine.postprocess import DEFAULT_FPS_MS, frame_durations
+from windup_ai_engine.master_prep import add_headroom, prepare_master
+
+
+# ── frame_durations ──────────────────────────────────────────────────────────
+#
+# 契约:等时长会让动作发飘、没有重量感,故各动作有不同基准,关键帧还要加长定格。
+# 下面的断言锁的是"动作之间必须有区分度"与"定格必须真的更长",不是锁具体数值。
+
+
+def test_durations_length_matches_frame_count():
+ assert len(frame_durations("walk", 16)) == 16
+ assert frame_durations("walk", 0) == []
+
+
+def test_each_action_has_its_own_base_duration():
+ """idle 慢、run 快 —— 若所有动作退化成同一个值,本用例失败。"""
+ idle = frame_durations("idle", 4)[0]
+ walk = frame_durations("walk", 4)[0]
+ run = frame_durations("run", 4)[0]
+ assert idle > walk > run, f"idle={idle} walk={walk} run={run} 应递减"
+ assert idle == DEFAULT_FPS_MS["idle"]
+
+
+def test_unknown_action_falls_back_to_walk_not_zero():
+ """未知动作要有可用的兜底,不能返回 0 或抛错——上游动作类型可能先于本模块扩展。"""
+ assert frame_durations("no_such_action", 3) == frame_durations("walk", 3)
+
+
+def test_key_frame_is_held_longer_than_its_neighbours():
+ """攻击触点 / 跳跃顶点要定格,否则动作没有重量感。"""
+ d = frame_durations("attack", 8, key_frame=3, hold_ms=180)
+ assert d[3] == 180
+ assert d[3] > d[2] and d[3] > d[4]
+ assert sum(1 for x in d if x == 180) == 1, "只应定格一帧"
+
+
+def test_key_frame_out_of_range_is_ignored_not_crashing():
+ """越界的 key_frame 不应炸 —— 帧数由选帧决定,调用方未必对齐。"""
+ assert frame_durations("attack", 4, key_frame=99) == frame_durations("attack", 4)
+ assert frame_durations("attack", 4, key_frame=-1) == frame_durations("attack", 4)
+
+
+def test_hold_never_shortens_a_frame():
+ """hold_ms 小于基准时长时取基准,定格不能反而变快。"""
+ base = DEFAULT_FPS_MS["idle"] # 450,远大于常见 hold 180
+ d = frame_durations("idle", 4, key_frame=1, hold_ms=100)
+ assert d[1] == base
+
+
+# ── prepare_master ───────────────────────────────────────────────────────────
+#
+# 契约:jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶)。
+# 故这两个动作要在母版顶部补空间,其余动作原样返回。
+
+
+def _png(w: int, h: int, fill=(200, 60, 60), bg=(18, 220, 30)) -> bytes:
+ """一张四角为纯背景色、中下部有主体的图。背景取绿幕色以便断言补边颜色。"""
+ img = Image.new("RGB", (w, h), bg)
+ for y in range(h // 3, h):
+ for x in range(w // 3, w * 2 // 3):
+ img.putpixel((x, y), fill)
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def _size(b: bytes) -> tuple[int, int]:
+ return Image.open(io.BytesIO(b)).size
+
+
+@pytest.mark.parametrize("action", ["jump", "attack"])
+def test_airborne_actions_get_headroom(action: str):
+ src = _png(64, 100)
+ out = prepare_master(src, action)
+ w0, h0 = _size(src)
+ w1, h1 = _size(out)
+ assert w1 == w0, "宽度不应变化"
+ assert h1 > h0, f"{action} 必须补顶部空间,否则腾空时头顶顶出画面被裁"
+
+
+@pytest.mark.parametrize("action", ["walk", "run", "idle", "hit", "unknown"])
+def test_other_actions_are_returned_untouched(action: str):
+ """不需要处理的动作必须**原样**返回 —— 无谓的重编码会引入压缩损失。"""
+ src = _png(64, 100)
+ assert prepare_master(src, action) is src
+
+
+def test_headroom_is_added_on_top_and_original_sits_at_bottom():
+ """补的边必须在**顶部**:原图贴底,顶部新增区域应为背景色。"""
+ src = _png(64, 90)
+ out = add_headroom(src, ratio=0.6)
+ src_img = Image.open(io.BytesIO(src)).convert("RGB")
+ out_img = Image.open(io.BytesIO(out)).convert("RGB")
+ added = out_img.height - src_img.height
+ assert added > 0
+
+ # 顶部新增区域 = 背景色
+ assert out_img.getpixel((2, 2)) == src_img.getpixel((0, 0))
+ # 原图整体落在底部:最后一行应与原图最后一行一致
+ assert out_img.crop((0, out_img.height - 1, out_img.width, out_img.height)).tobytes() == \
+ src_img.crop((0, src_img.height - 1, src_img.width, src_img.height)).tobytes()
+
+
+def test_smaller_ratio_gives_more_headroom():
+ """ratio 是"角色占画面高度的比例",越小顶部留白越多。"""
+ src = _png(64, 100)
+ _, h_loose = _size(add_headroom(src, ratio=0.5))
+ _, h_tight = _size(add_headroom(src, ratio=0.9))
+ assert h_loose > h_tight
+
+
+def test_jump_gets_more_headroom_than_attack():
+ """jump 向上腾空,需要的顶部空间比 attack 的过顶挥砍更多(0.62 vs 0.70)。"""
+ src = _png(64, 100)
+ _, h_jump = _size(prepare_master(src, "jump"))
+ _, h_attack = _size(prepare_master(src, "attack"))
+ assert h_jump > h_attack
+
+
+@pytest.mark.parametrize("bad", [0.0, 0.1, 1.0, 1.5, -0.3])
+def test_invalid_ratio_raises(bad: float):
+ with pytest.raises(ValueError, match="ratio"):
+ add_headroom(_png(32, 32), ratio=bad)
diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py
new file mode 100644
index 00000000..285b4d4c
--- /dev/null
+++ b/backend/tests/test_generation_api.py
@@ -0,0 +1,248 @@
+"""生成任务 API 的认证与资源归属测试。"""
+
+import asyncio
+
+import pytest
+from sqlalchemy.orm import sessionmaker
+
+from windup_app.web.api.generation import GenerationTaskOut, _EventBus
+
+from conftest import seed_credit_account
+
+
+@pytest.fixture(autouse=True)
+def _gift_credits(engine):
+ """提交生成任务会冻结积分,本文件用例都预置注册赠送账户。"""
+ with sessionmaker(bind=engine)() as session:
+ seed_credit_account(session, 1)
+ seed_credit_account(session, 2)
+ session.commit()
+
+
+def _create_project(auth_client, name: str = "生成项目") -> dict:
+ return auth_client.post(
+ "/projects",
+ json={
+ "project_name": name,
+ "character_perspective": 1,
+ "directional_movement": 2,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ },
+ ).json()["data"]
+
+
+def _create_character(auth_client, project_id: int) -> dict:
+ return auth_client.post(
+ "/characters",
+ json={
+ "project_id": project_id,
+ "workflow_run_id": 1,
+ "name": "勇者",
+ },
+ ).json()["data"]
+
+
+def _image_payload(project_id: int, **overrides) -> dict:
+ payload = {
+ "project_id": project_id,
+ "prompt": "像素风勇者",
+ "width": 64,
+ "height": 64,
+ }
+ payload.update(overrides)
+ return payload
+
+
+def _action_payload(project_id: int, character_id: int, **overrides) -> dict:
+ payload = {
+ "project_id": project_id,
+ "character_id": character_id,
+ "action_type": "walk",
+ }
+ payload.update(overrides)
+ return payload
+
+
+def test_image_generation_uses_token_user_without_body_user_id(auth_client):
+ project = _create_project(auth_client)
+
+ response = auth_client.post(
+ "/generation/image",
+ json=_image_payload(project["id"]),
+ )
+
+ # 端点已接上服务层(本 PR),故断言真实行为:任务建成、归属取自 token 而不是请求体。
+ body = response.json()
+ assert body["data"] is not None, body
+ assert body["data"]["status"] == "pending"
+
+
+def test_spoofed_body_user_id_cannot_access_other_users_project(
+ auth_client,
+ auth_client_b,
+):
+ project = _create_project(auth_client)
+
+ response = auth_client_b.post(
+ "/generation/image",
+ json=_image_payload(project["id"], user_id=1),
+ )
+
+ assert response.json()["code"] == 404
+ assert response.json()["message"] == "项目不存在"
+
+
+def test_action_generation_uses_token_user_without_body_user_id(auth_client):
+ project = _create_project(auth_client)
+ character = _create_character(auth_client, project["id"])
+
+ response = auth_client.post(
+ "/generation/action",
+ json=_action_payload(project["id"], character["id"]),
+ )
+
+ # 端点已接上服务层(本 PR),故断言真实行为:任务建成、归属取自 token 而不是请求体。
+ body = response.json()
+ assert body["data"] is not None, body
+ assert body["data"]["status"] == "pending"
+
+
+def test_action_character_must_belong_to_requested_project(auth_client):
+ first_project = _create_project(auth_client, "项目一")
+ second_project = _create_project(auth_client, "项目二")
+ character = _create_character(auth_client, first_project["id"])
+
+ response = auth_client.post(
+ "/generation/action",
+ json=_action_payload(second_project["id"], character["id"]),
+ )
+
+ assert response.json()["code"] == 404
+ assert response.json()["message"] == "角色不存在"
+
+
+def test_task_query_checks_project_ownership(auth_client, auth_client_b):
+ project = _create_project(auth_client)
+
+ response = auth_client_b.get(
+ "/generation/tasks/1",
+ params={"project_id": project["id"]},
+ )
+
+ assert response.json()["code"] == 404
+ assert response.json()["message"] == "项目不存在"
+
+
+def test_task_stream_checks_project_ownership(auth_client, auth_client_b):
+ project = _create_project(auth_client)
+
+ response = auth_client_b.get(
+ "/generation/tasks/1/stream",
+ params={"project_id": project["id"]},
+ )
+
+ assert response.json()["code"] == 404
+ assert response.json()["message"] == "项目不存在"
+
+
+def test_event_bus_isolates_same_task_id_between_projects():
+ async def scenario():
+ bus = _EventBus()
+ first_queue = await bus.subscribe(1, 9)
+ second_queue = await bus.subscribe(2, 9)
+
+ bus.publish(1, 9, "progress", {"status": "running"})
+
+ assert first_queue.get_nowait() == (
+ "progress",
+ {"status": "running"},
+ )
+ assert second_queue.empty()
+
+ asyncio.run(scenario())
+
+
+def test_generation_response_contract_does_not_expose_user_id():
+ assert "user_id" not in GenerationTaskOut.model_fields
+
+
+# ── 端点必须真的接上服务层(2026-08-12 事故后补)──────────────────────────────
+#
+# 这三个端点曾在一次 rebase 里被换回 TODO 桩,CI 全绿、只有人工评审看出来。
+# 根因是没有任何测试断言"端点会落库"—— 桩返回 400、测试也断言 400,两边一致。
+
+
+def _submit_image(auth_client, project_id: int):
+ return auth_client.post("/generation/image", json=_image_payload(project_id)).json()
+
+
+def test_image_endpoint_actually_creates_a_task_row(auth_client):
+ """提交后必须有一条 PENDING 任务落库,而不是抛"接口待实现"。"""
+ project = _create_project(auth_client)
+ body = _submit_image(auth_client, project["id"])
+ assert body["data"] is not None, body
+ task_id = body["data"]["id"]
+
+ got = auth_client.get(f"/generation/tasks/{task_id}",
+ params={"project_id": project["id"]}).json()
+ assert got["data"]["id"] == task_id
+ assert got["data"]["status"] == "pending"
+
+
+def test_action_endpoint_actually_creates_a_task_row(auth_client):
+ project = _create_project(auth_client)
+ character = _create_character(auth_client, project["id"])
+ body = auth_client.post(
+ "/generation/action",
+ json=_action_payload(project["id"], character["id"]),
+ ).json()
+ assert body["data"] is not None, body
+ assert body["data"]["status"] == "pending"
+
+
+def test_task_query_rejects_a_task_from_another_project(auth_client):
+ """归属两道:项目属于我 + 任务属于该项目。
+
+ 只查项目不够 —— 任意已认证用户拿自己的 project_id 配上别人的 task_id
+ 就能读到别人的产物 URL。用同一用户的两个项目复现,排除"项目校验挡住了"。
+ """
+ mine = _create_project(auth_client, "我的项目")
+ other = _create_project(auth_client, "另一个项目")
+ task_id = _submit_image(auth_client, other["id"])["data"]["id"]
+
+ got = auth_client.get(f"/generation/tasks/{task_id}",
+ params={"project_id": mine["id"]}).json()
+ assert got["data"] is None, got
+
+
+def test_response_conversion_path_is_live(auth_client):
+ """_task_to_out 必须真的被调用。
+
+ 它曾定义了没人调用 —— 那正是"整层没接上"的旁证之一。这里断言响应形状确实
+ 来自它(含 status / task_type 等领域字段),而不是随便一个 dict。
+ """
+ project = _create_project(auth_client)
+ data = _submit_image(auth_client, project["id"])["data"]
+ for k in ("id", "status", "task_type"):
+ assert k in data, f"缺字段 {k}:{data}"
+ assert "user_id" not in data, "响应不该暴露 user_id"
+
+
+def test_validation_error_message_tells_the_user_what_is_wrong(auth_client):
+ """校验失败的 message 必须是可读原因,不是一句笼统的"请求参数校验失败"。
+
+ 前端展示的是 message;把原因只塞进 data 等于用户永远看不到 —— 实测用户看到的是
+ 读不懂的"请求参数校验失败",而"custom 动作必须提供 custom_prompt"就在 data 里躺着。
+ """
+ project = _create_project(auth_client)
+ r = auth_client.post("/generation/action", json={
+ "project_id": project["id"], "character_id": 1,
+ "action_type": "custom", "custom_prompt": "",
+ "num_frames": 32, "reference_image_urls": ["https://media.windup.xin/x.png"],
+ })
+ body = r.json()
+ assert body["code"] == 400
+ assert body["message"] != "请求参数校验失败", "还是笼统文案,用户看不懂"
+ assert "custom_prompt" in body["message"] or "动作" in body["message"]
+ assert not body["message"].startswith("Value error,"), "pydantic 前缀是噪声,该剥掉"
diff --git a/backend/tests/test_generation_orchestration.py b/backend/tests/test_generation_orchestration.py
new file mode 100644
index 00000000..cf1d9969
--- /dev/null
+++ b/backend/tests/test_generation_orchestration.py
@@ -0,0 +1,326 @@
+"""生成任务编排端到端(离线):提交任务 → 后台调 ai_engine 出帧 → 上传 → 写回结果。
+
+用内存 sqlite + 真实 GenerationTaskRecord ORM + 真实 AiGenerationService + 真实
+CharacterGenerator(视频 provider / matte / 抽帧全部桩替,不联网、不碰对象存储)。
+证明"任务 → ai_engine → 帧 → COMPLETED"这条链真能跑通。
+"""
+from __future__ import annotations
+
+import io
+
+import pytest
+from PIL import Image
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from windup_framework.db.base import Base
+from windup_app.server.project.model import Project # 注册 windup_project 表(create_all 用)
+from windup_app.server.quota.model import CreditAccount
+from windup_app.server.orchestrator.model import (
+ ActionType,
+ CharacterActionInput,
+ CharacterActionOutput,
+ TaskStatus,
+)
+from windup_app.server.orchestrator.executor import ActionTaskExecutor
+from windup_app.server.orchestrator.service import AiGenerationService
+from windup_ai_engine.impl import CharacterGenerator
+from windup_ai_engine.strategy.concrete import VideoFrameStrategy
+from windup_common.models import GenRoute
+from windup_framework.config.quota import settings as quota_settings
+
+
+def _tiny_png(shift: int = 0) -> bytes:
+ img = Image.new("RGBA", (64, 96), (0, 0, 0, 0))
+ for y in range(20, 80):
+ for x in range(24 + shift, 40 + shift):
+ img.putpixel((x, y), (200, 60, 60, 255))
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _StubVideo:
+ def i2v(self, first_frame, prompt, seconds=5, size="1280x720"):
+ return b"fake-mp4"
+
+
+class _StubMatte:
+ def cutout(self, frame):
+ return frame
+
+
+@pytest.fixture
+def session_factory():
+ """共享的内存 sqlite(StaticPool 保证多 session 同库),建好任务表。"""
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(engine)
+ factory = sessionmaker(bind=engine)
+ with factory() as session:
+ session.add(CreditAccount(
+ user_id=1,
+ balance=quota_settings.register_gift_amount,
+ frozen=0,
+ total_earned=quota_settings.register_gift_amount,
+ total_spent=0,
+ ))
+ session.commit()
+ return factory
+
+
+def _real_offline_generator(monkeypatch) -> CharacterGenerator:
+ """真实 CharacterGenerator,但抽帧顶替成合成帧(不解码 mp4 / 不联网)。"""
+ dense = [
+ Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA")
+ for i in range(24)
+ ]
+ monkeypatch.setattr(
+ "windup_ai_engine.strategy.concrete.extract_all_frames_bytes",
+ lambda video, cap=150: dense,
+ )
+ return CharacterGenerator(
+ {GenRoute.VIDEO_I2V: VideoFrameStrategy(_StubVideo(), _StubMatte())}
+ )
+
+
+def test_action_task_runs_end_to_end(session_factory, monkeypatch):
+ uploaded: list[bytes] = []
+
+ def _upload(png: bytes) -> str:
+ uploaded.append(png)
+ return f"https://cdn.example.com/frame-{len(uploaded)}.png"
+
+ service = AiGenerationService()
+ executor = ActionTaskExecutor(
+ generator=_real_offline_generator(monkeypatch),
+ upload=_upload,
+ fetch_master=lambda _input: _tiny_png(),
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=6,
+ )
+
+ # 1) 提交:建 PENDING 任务
+ with session_factory() as s:
+ task = service.generate_character_action(s, user_id=1, input=action_input)
+ s.commit()
+ task_id = task.id
+ assert task.status is TaskStatus.PENDING
+
+ # 2) 后台跑(自开 session)
+ executor.run_action_task(task_id, action_input)
+
+ # 3) 轮询:任务 COMPLETED,结果是含 URL 的帧序列
+ with session_factory() as s:
+ done = service.get_task(s, project_id=1, task_id=task_id)
+ assert done is not None
+ assert done.status is TaskStatus.COMPLETED
+ assert isinstance(done.result, CharacterActionOutput)
+ assert done.result.action_type == "walk"
+ assert len(done.result.frames) >= 1
+ assert uploaded, "应逐帧上传"
+ for i, frame in enumerate(done.result.frames):
+ assert frame.index == i
+ assert frame.image_url.startswith("https://")
+ assert frame.duration_ms is not None
+
+
+def _png_of(w: int, h: int) -> bytes:
+ """指定尺寸的一张带主体的 PNG。"""
+ img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ img.paste((200, 60, 60, 255), (w // 4, h // 4, w // 2, h // 2))
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _SpyGenerator:
+ """记录传入的 facing / canvas,验证项目约束确实喂进了 ai_engine。
+
+ ``canvas`` 是必须接的:交付尺寸现在由引擎按项目 sprite 尺寸出帧负责,编排层
+ 不再拿到帧之后自己缩 —— 那一步用 thumbnail 补边,只缩不放,会把引擎对齐好的
+ 脚线挪走。本 spy 照真实引擎的约定按 canvas 出帧。
+
+ ``honour_canvas=False`` 用来模拟"引擎没按尺寸出帧",验证编排层会报错而不是
+ 静默缩放补救。
+ """
+
+ def __init__(self, honour_canvas: bool = True) -> None:
+ self.seen_facing: str | None = None
+ self.seen_canvas: tuple[int, int] | None = None
+ self.seen_action = None
+ self._honour = honour_canvas
+
+ def generate(self, card, action, master, progress, canvas=None):
+ from windup_ai_engine.ports import GeneratedAction
+
+ self.seen_facing = action.facing
+ self.seen_canvas = canvas
+ self.seen_action = action
+ size = canvas if (canvas and self._honour) else (256, 256)
+ # 不传 fps:GeneratedAction 早已删掉该字段(播放时序的唯一真相源是 durations)。
+ # 这个 spy 之前一直在传,构造直接 TypeError、任务被判 FAILED —— 而当时的用例
+ # 只断言 seen_facing(在构造之前就赋了值),于是**用例绿着、任务其实是失败的**。
+ from windup_ai_engine.ports import ActionQuality
+
+ return GeneratedAction(
+ frames=[_png_of(*size)],
+ durations=[100],
+ quality=ActionQuality(motion_scale=1.0, dead_frames=[], loop_seam=None),
+ )
+
+
+def test_project_perspective_constrains_facing(session_factory):
+ # perspective=2 → front(见 executor._PERSPECTIVE_TO_FACING)
+ with session_factory() as s:
+ proj = Project(
+ user_id=1, project_name="p", character_perspective=2,
+ directional_movement=1, sprite_width=64, sprite_height=64,
+ )
+ s.add(proj)
+ s.commit()
+ project_id = proj.id
+
+ spy = _SpyGenerator()
+ executor = ActionTaskExecutor(
+ generator=spy,
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: _tiny_png(),
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as s:
+ task = AiGenerationService().generate_character_action(s, user_id=1, input=action_input)
+ s.commit()
+ task_id = task.id
+
+ executor.run_action_task(task_id, action_input, project_id) # 带项目约束
+
+ assert spy.seen_facing == "front", "项目 perspective 应约束生成朝向"
+
+
+def test_custom_action_reuses_oneshot_route_and_preserves_prompt(session_factory):
+ spy = _SpyGenerator()
+ executor = ActionTaskExecutor(
+ generator=spy,
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: _tiny_png(),
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1,
+ action_type=ActionType.CUSTOM,
+ custom_prompt="wave hello with the right hand",
+ num_frames=2,
+ )
+ with session_factory() as s:
+ task = AiGenerationService().generate_character_action(s, user_id=1, input=action_input)
+ s.commit()
+ task_id = task.id
+
+ executor.run_action_task(task_id, action_input)
+
+ assert spy.seen_action.action.value == "custom"
+ assert spy.seen_action.custom_action == "wave hello with the right hand"
+ # loop 没给 → 兜成一次性(失败代价不对称,见 executor 里的说明),而不是抛错。
+ assert spy.seen_action.cyclic is False
+
+
+def test_action_task_marks_failed_on_error(session_factory):
+ def _boom(_input):
+ raise RuntimeError("母版下载失败")
+
+ service = AiGenerationService()
+ executor = ActionTaskExecutor(
+ generator=None, # 不会用到:取母版先炸
+ fetch_master=_boom,
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=4,
+ )
+ with session_factory() as s:
+ task = service.generate_character_action(s, user_id=1, input=action_input)
+ s.commit()
+ task_id = task.id
+
+ executor.run_action_task(task_id, action_input) # 不抛,兜底为 FAILED
+
+ with session_factory() as s:
+ done = service.get_task(s, project_id=1, task_id=task_id)
+ assert done.status is TaskStatus.FAILED
+ assert "母版下载失败" in (done.error_message or "")
+
+
+# ── 交付尺寸传给引擎(2026-08-11 挣得)──────────────────────────────────────────
+#
+# 这里以前是拿到 256 的帧再 _fit_to 到项目 sprite 尺寸。那步用 Image.thumbnail 补边,
+# 而 thumbnail **只缩不放**:项目要 512 时帧根本不会被放大,只是原尺寸居中贴进 512
+# 画布,于是引擎刚对齐好的脚线 0.92 被挪到 0.709(实测),角色不站在地上。
+# 现在尺寸交给引擎(canvas),编排层只核对、不缩放。
+
+
+def _run_with_project(session_factory, spy, sprite=(64, 64)):
+ """建一个指定 sprite 尺寸的项目,跑一次动作任务,返回 (task_id, project_id)。"""
+ with session_factory() as s:
+ proj = Project(
+ user_id=1, project_name="p", character_perspective=1,
+ directional_movement=1, sprite_width=sprite[0], sprite_height=sprite[1],
+ )
+ s.add(proj)
+ s.commit()
+ project_id = proj.id
+
+ executor = ActionTaskExecutor(
+ generator=spy,
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: _tiny_png(),
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as s:
+ task = AiGenerationService().generate_character_action(
+ s, user_id=1, input=action_input
+ )
+ s.commit()
+ task_id = task.id
+ executor.run_action_task(task_id, action_input, project_id)
+ return task_id, project_id
+
+
+def test_project_sprite_size_is_passed_to_the_engine(session_factory):
+ """项目 sprite 尺寸必须作为 canvas 传进引擎 —— 而不是拿到帧再缩。"""
+ spy = _SpyGenerator()
+ _run_with_project(session_factory, spy, sprite=(512, 512))
+ assert spy.seen_canvas == (512, 512), "引擎应当收到项目 sprite 尺寸"
+
+
+def test_non_square_project_sprite_size_is_passed_through(session_factory):
+ """非方形项目尺寸也要原样传下去,不能只传一个边长。"""
+ spy = _SpyGenerator()
+ _run_with_project(session_factory, spy, sprite=(384, 512))
+ assert spy.seen_canvas == (384, 512)
+
+
+def test_engine_frame_of_wrong_size_fails_instead_of_being_rescaled(session_factory):
+ """引擎没按尺寸出帧 → 任务失败,**不做静默缩放补救**。
+
+ 以前这里会 _fit_to 补救,把"引擎没按尺寸出帧"抹平,代价是脚线对齐被破坏 ——
+ 正是本仓最忌讳的"看起来成功的错产物"。
+ """
+ spy = _SpyGenerator(honour_canvas=False) # 恒出 256,无视 canvas
+ task_id, _ = _run_with_project(session_factory, spy, sprite=(512, 512))
+ with session_factory() as s:
+ done = AiGenerationService().get_task(s, project_id=1, task_id=task_id)
+ assert done.status is TaskStatus.FAILED, "尺寸对不上必须失败,不能悄悄缩放交付"
+ assert "512" in (done.error_message or ""), "报错要说清期望尺寸"
diff --git a/backend/tests/test_generation_quota.py b/backend/tests/test_generation_quota.py
new file mode 100644
index 00000000..cbac6e1b
--- /dev/null
+++ b/backend/tests/test_generation_quota.py
@@ -0,0 +1,594 @@
+"""生成任务调度必须走预付费冻结 / 扣减 / 解冻。"""
+
+import pytest
+from sqlalchemy import create_engine, select
+from sqlalchemy.orm import Session, sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from windup_framework.db.base import Base
+from windup_app.server.orchestrator import billing
+from windup_app.server.orchestrator.executor import ActionTaskExecutor, ImageTaskExecutor
+from windup_app.server.orchestrator.model import (
+ ActionType,
+ CharacterActionInput,
+ CharacterImageInput,
+ GenerationTaskRecord,
+ GenerationType,
+ TaskStatus,
+)
+from windup_app.server.orchestrator.service import AiGenerationService
+from windup_app.server.quota.model import CreditAccount, CreditTransaction
+from windup_common.enums.quota import CreditReason
+from windup_common.exceptions import BizException
+from windup_framework.config.quota import settings as quota_settings
+
+from conftest import seed_credit_account
+from test_generation_orchestration import _SpyGenerator, _tiny_png
+
+
+@pytest.fixture
+def session_factory():
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(engine)
+ return sessionmaker(bind=engine)
+
+
+def _seed_account(session: Session, user_id: int, *, balance: int | None = None) -> CreditAccount:
+ return seed_credit_account(session, user_id, balance=balance)
+
+
+def _account(session: Session, user_id: int) -> CreditAccount:
+ return session.scalar(select(CreditAccount).where(CreditAccount.user_id == user_id))
+
+
+def _reasons(session: Session, user_id: int) -> list[int]:
+ rows = session.scalars(
+ select(CreditTransaction)
+ .where(CreditTransaction.user_id == user_id)
+ .order_by(CreditTransaction.id)
+ ).all()
+ return [row.reason for row in rows]
+
+
+def test_submit_image_generation_reserves_prepaid_credit(auth_client, db_session):
+ _seed_account(db_session, 1)
+ db_session.commit()
+
+ project = auth_client.post(
+ "/projects",
+ json={
+ "project_name": "积分项目",
+ "character_perspective": 1,
+ "directional_movement": 2,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ },
+ ).json()["data"]
+
+ response = auth_client.post(
+ "/generation/image",
+ json={"project_id": project["id"], "prompt": "勇者", "width": 64, "height": 64},
+ )
+ body = response.json()
+ assert body["data"] is not None, body
+ task_id = body["data"]["id"]
+
+ account = _account(db_session, 1)
+ db_session.refresh(account)
+ assert account.frozen == quota_settings.generate_image_cost
+ assert account.balance == quota_settings.register_gift_amount - quota_settings.generate_image_cost
+ assert _reasons(db_session, 1) == [CreditReason.FROZEN]
+ txn = db_session.scalar(select(CreditTransaction).where(CreditTransaction.user_id == 1))
+ assert txn.ref_id == f"task:{task_id}"
+
+
+def test_submit_rejects_when_credit_is_insufficient(auth_client, db_session):
+ _seed_account(db_session, 1, balance=1)
+ db_session.commit()
+
+ project = auth_client.post(
+ "/projects",
+ json={
+ "project_name": "没钱项目",
+ "character_perspective": 1,
+ "directional_movement": 2,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ },
+ ).json()["data"]
+
+ response = auth_client.post(
+ "/generation/image",
+ json={"project_id": project["id"], "prompt": "勇者", "width": 64, "height": 64},
+ )
+ body = response.json()
+ assert body["code"] == 400
+ assert "积分不足" in body["message"]
+
+ account = _account(db_session, 1)
+ db_session.refresh(account)
+ assert account.balance == 1
+ assert account.frozen == 0
+ assert db_session.scalar(select(CreditTransaction).where(CreditTransaction.user_id == 1)) is None
+
+
+def test_action_success_captures_reserved_credit(session_factory):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ service = AiGenerationService()
+ executor = ActionTaskExecutor(
+ generator=_SpyGenerator(),
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: _tiny_png(),
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ task = service.generate_character_action(session, user_id=1, input=action_input)
+ session.commit()
+ task_id = task.id
+
+ executor.run_action_task(task_id, action_input)
+
+ with session_factory() as session:
+ done = service.get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.COMPLETED
+ assert account.frozen == 0
+ assert account.total_spent == quota_settings.generate_action_cost
+ assert account.balance == quota_settings.register_gift_amount - quota_settings.generate_action_cost
+ assert CreditReason.CAPTURED in _reasons(session, 1)
+
+
+def test_action_failure_releases_reserved_credit(session_factory):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ service = AiGenerationService()
+ def _boom(_input):
+ raise RuntimeError("母版下载失败")
+
+ executor = ActionTaskExecutor(
+ generator=None,
+ fetch_master=_boom,
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ task = service.generate_character_action(session, user_id=1, input=action_input)
+ session.commit()
+ task_id = task.id
+
+ executor.run_action_task(task_id, action_input)
+
+ with session_factory() as session:
+ done = service.get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.FAILED
+ assert account.frozen == 0
+ assert account.total_spent == 0
+ assert account.balance == quota_settings.register_gift_amount
+ assert CreditReason.REFUND in _reasons(session, 1)
+
+
+def test_generate_character_action_without_account_raises(session_factory):
+ service = AiGenerationService()
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ with pytest.raises(BizException, match="积分账户不存在"):
+ service.generate_character_action(session, user_id=1, input=action_input)
+ session.rollback()
+ assert session.scalar(select(GenerationTaskRecord)) is None
+
+
+def test_capture_uses_frozen_amount_when_price_rises(session_factory, monkeypatch):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ service = AiGenerationService()
+ executor = ActionTaskExecutor(
+ generator=_SpyGenerator(),
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: _tiny_png(),
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ task = service.generate_character_action(session, user_id=1, input=action_input)
+ session.commit()
+ task_id = task.id
+ frozen_at_submit = quota_settings.generate_action_cost
+
+ monkeypatch.setattr(quota_settings, "generate_action_cost", frozen_at_submit + 40)
+ executor.run_action_task(task_id, action_input)
+
+ with session_factory() as session:
+ done = service.get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.COMPLETED
+ assert account.frozen == 0
+ assert account.total_spent == frozen_at_submit
+ assert account.balance == quota_settings.register_gift_amount - frozen_at_submit
+
+
+def test_release_uses_frozen_amount_when_price_falls(session_factory, monkeypatch):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ service = AiGenerationService()
+
+ def _boom(_input):
+ raise RuntimeError("母版下载失败")
+
+ executor = ActionTaskExecutor(
+ generator=None,
+ fetch_master=_boom,
+ session_factory=session_factory,
+ )
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ task = service.generate_character_action(session, user_id=1, input=action_input)
+ session.commit()
+ task_id = task.id
+ frozen_at_submit = quota_settings.generate_action_cost
+
+ monkeypatch.setattr(quota_settings, "generate_action_cost", max(1, frozen_at_submit - 40))
+ executor.run_action_task(task_id, action_input)
+
+ with session_factory() as session:
+ account = _account(session, 1)
+ assert account.frozen == 0
+ assert account.balance == quota_settings.register_gift_amount
+
+
+def test_recover_requeues_pending_tasks_with_open_freeze(session_factory):
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ queued: list[tuple] = []
+
+ class _Dispatcher:
+ def submit(self, target, *args):
+ queued.append((target, args))
+
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ service = AiGenerationService()
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ task = service.generate_character_action(
+ session, user_id=1, project_id=7, input=action_input,
+ )
+ session.commit()
+ task_id = task.id
+
+ def _run_image(*_args):
+ raise AssertionError("不应入队图片任务")
+
+ def _run_action(*_args):
+ raise AssertionError("recover 只负责入队,不直接跑")
+
+ with session_factory() as session:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=_Dispatcher(),
+ run_image_task=_run_image,
+ run_action_task=_run_action,
+ )
+ session.commit()
+
+ assert len(queued) == 1
+ _target, args = queued[0]
+ assert _target is _run_action
+ assert args[0] == task_id
+ assert args[1].action_type is ActionType.WALK
+ assert args[2] == 7
+
+ with session_factory() as session:
+ still = service.get_task(session, project_id=7, task_id=task_id)
+ assert still.status is TaskStatus.PENDING
+ assert _account(session, 1).frozen == quota_settings.generate_action_cost
+
+
+def test_recover_fails_and_unfreezes_running_orphans(session_factory):
+ from windup_app.server.orchestrator import task_repo
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ service = AiGenerationService()
+ action_input = CharacterActionInput(
+ character_id=1, action_type=ActionType.WALK, num_frames=2,
+ )
+ with session_factory() as session:
+ task = service.generate_character_action(session, user_id=1, input=action_input)
+ task_repo.update_status(session, task.id, TaskStatus.RUNNING)
+ session.commit()
+ task_id = task.id
+
+ with session_factory() as session:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=type("D", (), {"submit": staticmethod(lambda *a, **k: None)})(),
+ run_image_task=lambda *a: None,
+ run_action_task=lambda *a: None,
+ )
+ session.commit()
+
+ with session_factory() as session:
+ done = service.get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.FAILED
+ assert "中断" in (done.error_message or "")
+ assert account.frozen == 0
+ assert account.balance == quota_settings.register_gift_amount
+ assert CreditReason.REFUND in _reasons(session, 1)
+
+
+def test_prepaid_cost_by_task_type():
+ assert billing.prepaid_cost(GenerationType.CHARACTER_IMAGE) == quota_settings.generate_image_cost
+ assert billing.prepaid_cost(GenerationType.CHARACTER_ACTION) == quota_settings.generate_action_cost
+ with pytest.raises(ValueError, match="未知生成类型"):
+ billing.prepaid_cost("not-a-type") # type: ignore[arg-type]
+
+
+def test_frozen_amount_missing_raises(session_factory):
+ with session_factory() as session:
+ with pytest.raises(BizException, match="找不到该任务的冻结流水"):
+ billing.frozen_amount_for_task(session, 999)
+
+
+def test_has_open_freeze_false_without_frozen_txn(session_factory):
+ with session_factory() as session:
+ assert billing.has_open_freeze(session, 1) is False
+
+
+def test_has_open_freeze_false_after_capture_or_release(session_factory):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+ service = AiGenerationService()
+ image_input = CharacterImageInput(prompt="x", width=64, height=64)
+ with session_factory() as session:
+ captured = service.generate_character_image(session, user_id=1, input=image_input)
+ billing.capture_for_task(session, user_id=1, task_id=captured.id)
+ released = service.generate_character_image(session, user_id=1, input=image_input)
+ billing.release_for_task(session, user_id=1, task_id=released.id)
+ session.commit()
+ assert billing.has_open_freeze(session, captured.id) is False
+ assert billing.has_open_freeze(session, released.id) is False
+
+
+def test_image_success_captures_reserved_credit(session_factory):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ executor = ImageTaskExecutor(
+ upload=lambda _png: "https://cdn.example.com/img.png",
+ session_factory=session_factory,
+ )
+ executor._produce_image = lambda _input, _cons: ["https://cdn.example.com/img.png"]
+ image_input = CharacterImageInput(prompt="勇者", width=64, height=64)
+ with session_factory() as session:
+ task = AiGenerationService().generate_character_image(
+ session, user_id=1, input=image_input,
+ )
+ session.commit()
+ task_id = task.id
+
+ executor.run_image_task(task_id, image_input)
+
+ with session_factory() as session:
+ done = AiGenerationService().get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.COMPLETED
+ assert account.frozen == 0
+ assert account.total_spent == quota_settings.generate_image_cost
+ assert CreditReason.CAPTURED in _reasons(session, 1)
+
+
+def test_image_failure_releases_reserved_credit(session_factory):
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ executor = ImageTaskExecutor(session_factory=session_factory)
+
+ def _boom(_input, _cons):
+ raise RuntimeError("出图失败")
+
+ executor._produce_image = _boom
+ image_input = CharacterImageInput(prompt="勇者", width=64, height=64)
+ with session_factory() as session:
+ task = AiGenerationService().generate_character_image(
+ session, user_id=1, input=image_input,
+ )
+ session.commit()
+ task_id = task.id
+
+ executor.run_image_task(task_id, image_input)
+
+ with session_factory() as session:
+ done = AiGenerationService().get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.FAILED
+ assert account.frozen == 0
+ assert account.balance == quota_settings.register_gift_amount
+
+
+def test_recover_skips_pending_without_open_freeze(session_factory):
+ from windup_app.server.orchestrator import task_repo
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ queued: list = []
+
+ class _Dispatcher:
+ def submit(self, target, *args):
+ queued.append((target, args))
+
+ with session_factory() as session:
+ task_repo.create_task(
+ session, user_id=1, project_id=1,
+ task_type=GenerationType.CHARACTER_IMAGE,
+ input_payload={"prompt": "x"},
+ )
+ session.commit()
+
+ with session_factory() as session:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=_Dispatcher(),
+ run_image_task=lambda *a: None,
+ run_action_task=lambda *a: None,
+ )
+
+ assert queued == []
+
+
+def test_recover_requeues_pending_image_tasks(session_factory):
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ queued: list[tuple] = []
+
+ class _Dispatcher:
+ def submit(self, target, *args):
+ queued.append((target, args))
+
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ image_input = CharacterImageInput(prompt="勇者", width=64, height=64)
+ with session_factory() as session:
+ task = AiGenerationService().generate_character_image(
+ session, user_id=1, project_id=3, input=image_input,
+ )
+ session.commit()
+ task_id = task.id
+
+ def _run_image(*_args):
+ raise AssertionError("只入队")
+
+ with session_factory() as session:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=_Dispatcher(),
+ run_image_task=_run_image,
+ run_action_task=lambda *a: None,
+ )
+
+ assert len(queued) == 1
+ target, args = queued[0]
+ assert target is _run_image
+ assert args[0] == task_id
+ assert args[1].prompt == "勇者"
+ assert args[2] == 3
+
+
+def test_recover_unfreezes_when_requeue_fails(session_factory):
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ class _BoomDispatcher:
+ def submit(self, *_args, **_kwargs):
+ raise RuntimeError("队列不可用")
+
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ with session_factory() as session:
+ task = AiGenerationService().generate_character_action(
+ session, user_id=1,
+ input=CharacterActionInput(character_id=1, action_type=ActionType.WALK, num_frames=2),
+ )
+ session.commit()
+ task_id = task.id
+
+ with session_factory() as session:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=_BoomDispatcher(),
+ run_image_task=lambda *a: None,
+ run_action_task=lambda *a: None,
+ )
+ session.commit()
+
+ with session_factory() as session:
+ done = AiGenerationService().get_task(session, project_id=1, task_id=task_id)
+ account = _account(session, 1)
+ assert done.status is TaskStatus.FAILED
+ assert account.frozen == 0
+ assert account.balance == quota_settings.register_gift_amount
+
+
+def test_recover_unfreezes_unknown_task_type(session_factory, monkeypatch):
+ from windup_app.server.orchestrator import task_repo
+ from windup_app.server.orchestrator.recover import recover_orphaned_generation_tasks
+
+ queued: list = []
+
+ class _Dispatcher:
+ def submit(self, target, *args):
+ queued.append((target, args))
+
+ with session_factory() as session:
+ _seed_account(session, 1)
+ session.commit()
+
+ with session_factory() as session:
+ task = AiGenerationService().generate_character_action(
+ session, user_id=1,
+ input=CharacterActionInput(character_id=1, action_type=ActionType.WALK, num_frames=2),
+ )
+ session.commit()
+ task_id = task.id
+
+ original = task_repo.list_by_status
+
+ def _unknown_type(session, statuses):
+ tasks = original(session, statuses)
+ for item in tasks:
+ item.task_type = "future_kind" # type: ignore[assignment]
+ return tasks
+
+ monkeypatch.setattr(task_repo, "list_by_status", _unknown_type)
+
+ with session_factory() as session:
+ recover_orphaned_generation_tasks(
+ session,
+ dispatcher=_Dispatcher(),
+ run_image_task=lambda *a: None,
+ run_action_task=lambda *a: None,
+ )
+ session.commit()
+
+ assert queued == []
+ with session_factory() as session:
+ done = AiGenerationService().get_task(session, project_id=1, task_id=task_id)
+ assert done.status is TaskStatus.FAILED
+ assert _account(session, 1).frozen == 0
diff --git a/backend/tests/test_generation_stream_auth.py b/backend/tests/test_generation_stream_auth.py
new file mode 100644
index 00000000..e5f7db11
--- /dev/null
+++ b/backend/tests/test_generation_stream_auth.py
@@ -0,0 +1,246 @@
+"""SSE 订阅的归属校验与终态预检。
+
+两条都是"测试全绿的情况下"存在的缺口:
+- 主线 #110 已经校验了"项目属于当前用户",但**没有**校验"任务属于那个项目"。缺这一道,
+ 任意已认证用户拿自己的 project_id 配上别人的 task_id 就能订阅到别人的流,而事件体带
+ result,即最终帧的对象存储 URL。
+- 终态预检原先是一行 TODO,而端点 docstring 已经承诺了该行为 —— 读文档的人不会发现,
+ 实际表现是客户端要先挂满一次心跳超时才拿到终态。
+
+归属口径沿用主线:靠 project_id 而不是任务自己的 user_id。我此前那版删掉了 project_id
+改用 task.user_id,方向是错的 —— project_id 在主线里正是归属校验的依据,且 EventBus
+按 (project_id, task_id) 双键隔离,删掉它会退化主线已有的能力。
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from windup_app.server.orchestrator import task_repo
+from windup_app.server.orchestrator.model import GenerationType, TaskStatus
+
+# SSE 事件体的键集是**对外契约**,故在这里写死。
+# 不要用 task_event_payload(task) 反算期望值 —— 那样两边同源,删字段时一起变、断言永远
+# 成立(2026-08-11 变异测试逮到第一版正是如此:删掉 error_message 仍全绿)。
+_EVENT_KEYS = {
+ "id", "user_id", "project_id", "task_type",
+ "status", "input_payload", "result", "error_message",
+}
+
+
+def _create_project(client, name: str = "SSE 项目") -> dict:
+ return client.post("/projects", json={
+ "project_name": name,
+ "character_perspective": 1,
+ "directional_movement": 2,
+ "sprite_width": 64,
+ "sprite_height": 64,
+ }).json()["data"]
+
+
+@pytest.fixture()
+def session(engine):
+ """绑定测试 engine 的 session,并补建 generation_task 表。
+
+ conftest 的 ``engine`` fixture 只建了 project / user / character / workflow_run
+ 四张,本 PR 新引入的这张不在那份清单里。在这里补建而不是改公共 fixture,
+ 是为了不影响其它测试文件的建表集合。
+ """
+ from sqlalchemy.orm import sessionmaker
+
+ from windup_app.server.orchestrator.model import GenerationTaskRecord
+ from windup_framework.db import Base
+
+ Base.metadata.create_all(engine, tables=[GenerationTaskRecord.__table__])
+ s = sessionmaker(bind=engine, expire_on_commit=False)()
+ yield s
+ s.close()
+
+
+def _make_task(session, *, user_id: int, project_id: int,
+ status: TaskStatus = TaskStatus.PENDING) -> int:
+ """直接落一条任务,绕过端点(端点会真的起后台线程去跑生成)。"""
+ task = task_repo.create_task(
+ session,
+ user_id=user_id,
+ project_id=project_id,
+ task_type=GenerationType.CHARACTER_IMAGE,
+ input_payload={"prompt": "x"},
+ )
+ if status is not TaskStatus.PENDING:
+ task_repo.update_status(session, task.id, status)
+ session.commit()
+ return task.id
+
+
+# ── ① 任务必须属于所声明的项目 ──────────────────────────────────────────────
+
+
+def test_task_from_another_project_cannot_be_subscribed(auth_client, session):
+ """主线只校验了"项目属于我",没校验"任务属于该项目"。
+
+ 缺这一道:**用自己的项目 id 配别人的任务 id** 就能订阅到别人的流。本用例用同一个
+ 用户的两个项目复现,因此排除了"项目归属校验挡住了"这种解释 —— 两个项目都属于我,
+ 唯一的区别是任务不在我声明的那个项目里。
+ """
+ mine = _create_project(auth_client, "我的项目")
+ other = _create_project(auth_client, "另一个项目")
+ task_id = _make_task(session, user_id=1, project_id=other["id"],
+ status=TaskStatus.COMPLETED)
+
+ with auth_client.stream(
+ "GET", f"/generation/tasks/{task_id}/stream",
+ params={"project_id": mine["id"]},
+ ) as r:
+ body = r.read().decode()
+ assert "event: " not in body, f"跨项目订阅拿到了事件流:{body[:200]}"
+
+
+def test_task_in_the_declared_project_is_subscribable(auth_client, session):
+ """对照组:任务确实在所声明的项目里时必须放行 —— 否则上一条可能只是全都拒了。"""
+ project = _create_project(auth_client)
+ task_id = _make_task(session, user_id=1, project_id=project["id"],
+ status=TaskStatus.COMPLETED)
+
+ with auth_client.stream(
+ "GET", f"/generation/tasks/{task_id}/stream",
+ params={"project_id": project["id"]},
+ ) as r:
+ body = r.read().decode()
+ assert "event: completed" in body, body[:200]
+
+
+def test_rejection_happens_before_subscribing(auth_client, session):
+ """校验必须在 subscribe **之前**。
+
+ 放在之后的话,越权请求仍会在 EventBus 上挂一个订阅者 —— 它照样收到事件,只是响应体
+ 被丢弃;订阅表还会因为没人 unsubscribe 而增长。
+ """
+ from windup_app.web.api.generation import event_bus
+
+ mine = _create_project(auth_client, "我的项目")
+ other = _create_project(auth_client, "另一个项目")
+ task_id = _make_task(session, user_id=1, project_id=other["id"])
+
+ with auth_client.stream(
+ "GET", f"/generation/tasks/{task_id}/stream",
+ params={"project_id": mine["id"]},
+ ) as r:
+ r.read()
+ key = (mine["id"], task_id)
+ assert not event_bus._queues.get(key), "越权请求在 EventBus 上留下了订阅者"
+
+
+# ── ② 终态预检 ─────────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize(("status", "expected"), [
+ (TaskStatus.COMPLETED, "event: completed"),
+ (TaskStatus.FAILED, "event: failed"),
+])
+def test_already_terminal_task_gets_its_event_immediately(auth_client, session, status, expected):
+ """订阅时任务已终结 → 立即推终态并关闭,而不是先挂满一次心跳超时。"""
+ project = _create_project(auth_client)
+ task_id = _make_task(session, user_id=1, project_id=project["id"], status=status)
+
+ with auth_client.stream(
+ "GET", f"/generation/tasks/{task_id}/stream",
+ params={"project_id": project["id"]},
+ ) as r:
+ body = r.read().decode()
+ assert expected in body, body[:300]
+ assert "heartbeat" not in body, "先发了心跳 = 没有走终态预检"
+
+
+def test_terminal_event_body_matches_the_documented_contract(auth_client, session):
+ """订阅时补发的终态事件,键集必须与契约一致。"""
+ project = _create_project(auth_client)
+ task_id = _make_task(session, user_id=1, project_id=project["id"],
+ status=TaskStatus.COMPLETED)
+
+ with auth_client.stream(
+ "GET", f"/generation/tasks/{task_id}/stream",
+ params={"project_id": project["id"]},
+ ) as r:
+ body = r.read().decode()
+ line = next(x for x in body.splitlines() if x.startswith("data: "))
+ assert set(json.loads(line[6:])) == _EVENT_KEYS
+
+
+def test_both_send_paths_use_the_same_payload_builder(session):
+ """运行中推送与终态补发必须同形状 —— 两处各抄一份字段列表迟早分叉。
+
+ 直接比两条真实路径的产出:``_publish_task_update``(运行中)与
+ ``task_event_payload``(终态预检用的那个)。
+ """
+ task_id = _make_task(session, user_id=1, project_id=42, status=TaskStatus.COMPLETED)
+ task = task_repo.get_task(session, task_id)
+
+ sent: list[dict] = []
+
+ class _Bus:
+ def publish(self, project_id, tid, event, data):
+ sent.append(data)
+
+ old_bus = task_repo._event_bus
+ task_repo._event_bus = _Bus()
+ try:
+ task_repo._publish_task_update(task_id, task)
+ finally:
+ task_repo._event_bus = old_bus
+
+ assert set(sent[0]) == _EVENT_KEYS
+ assert set(task_repo.task_event_payload(task)) == _EVENT_KEYS
+
+
+@pytest.mark.parametrize("status", [TaskStatus.PENDING, TaskStatus.RUNNING])
+def test_non_terminal_status_is_not_mistaken_for_terminal(status):
+ """非终态不能被预检判成终态,否则连接刚建立就被关掉。
+
+ **本条不走 HTTP,如实说明原因**:非终态的流是无限心跳,靠
+ ``request.is_disconnected()`` 退出,而 TestClient 下它不会变 True —— 生成器永不
+ 结束,TestClient 在 teardown 上阻塞(第一版这么写,把 pytest 挂满 10 分钟)。
+ 压低心跳间隔也无效,因为挂的不是等待、是退出条件。
+
+ 所以这里直接测预检用的那个判据函数。它是终态预检的唯一入口,改坏了上面那几条终态
+ 用例会红,因此覆盖不算空缺。
+ """
+ from windup_app.server.orchestrator.model import GenerationTask
+
+ task = GenerationTask(id=1, user_id=1, project_id=42,
+ task_type=GenerationType.CHARACTER_IMAGE, status=status)
+ assert task_repo.terminal_event_for(task) is None
+
+
+# ── ③ project_id 为空的任务发不出事件,要记 warning 而不是静默丢 ──────────────
+
+
+def test_task_without_project_id_logs_instead_of_publishing_into_the_void(session, caplog):
+ """EventBus 按 (project_id, task_id) 索引,project_id 为空就发不到任何订阅者。
+
+ 静默 publish 出去的话,现象是"任务确实在跑、状态也在落库,但前端进度条一动不动",
+ 而日志里一行异常都没有 —— 属于最难查的那类静默失败。
+ """
+ import logging
+
+ task_id = _make_task(session, user_id=1, project_id=42)
+ task = task_repo.get_task(session, task_id)
+ task.project_id = None
+
+ sent: list = []
+
+ class _Bus:
+ def publish(self, *a):
+ sent.append(a)
+
+ old_bus = task_repo._event_bus
+ task_repo._event_bus = _Bus()
+ try:
+ with caplog.at_level(logging.WARNING):
+ task_repo._publish_task_update(task_id, task)
+ finally:
+ task_repo._event_bus = old_bus
+
+ assert sent == [], "不该发到一个没人听的键上"
+ assert any("project_id" in r.message for r in caplog.records), "应记 warning"
diff --git a/backend/tests/test_loop.py b/backend/tests/test_loop.py
new file mode 100644
index 00000000..daac0c1e
--- /dev/null
+++ b/backend/tests/test_loop.py
@@ -0,0 +1,94 @@
+"""循环闭合(周期检测 + 单周期取帧)测试 —— 纯 CV,无需联网。"""
+
+import pytest
+from PIL import Image
+
+from windup_ai_engine.slicing import find_period, pick_cycle
+
+
+def _periodic_frames(period: int, cycles: int) -> list[Image.Image]:
+ """构造已知周期的帧序列:亮度按周期正弦变化(每帧一张纯灰图)。"""
+ import math
+
+ frames = []
+ for i in range(period * cycles):
+ v = int(128 + 100 * math.sin(2 * math.pi * i / period))
+ frames.append(Image.new("RGB", (48, 48), (v, v, v)))
+ return frames
+
+
+def test_find_period_detects_known_period():
+ frames = _periodic_frames(period=20, cycles=5)
+ p = find_period(frames)
+ assert abs(p - 20) <= 1 # 检出周期 ≈ 真值
+
+
+def test_pick_cycle_returns_n_frames():
+ frames = _periodic_frames(period=20, cycles=5)
+ out = pick_cycle(frames, 8)
+ assert len(out) == 8
+
+
+def _ramp_frames(n: int = 40) -> list[Image.Image]:
+ """亮度单调上升 = 无周期,专走"测不到周期"那条分支。"""
+ return [Image.new("RGB", (48, 48), (i * 4,) * 3) for i in range(n)]
+
+
+def test_pick_cycle_rejects_insufficient_source():
+ """源帧不够就报错(原本原样返回 4 帧,冒充"抽到了 8 帧的循环")。"""
+ frames = _periodic_frames(period=4, cycles=1) # 4 帧 < 8
+ with pytest.raises(ValueError, match=r"源帧不足.*8.*4"):
+ pick_cycle(frames, 8)
+ with pytest.raises(ValueError, match="源帧不足"): # 只差一帧也不放过(挡 off-by-one)
+ pick_cycle(frames, len(frames) + 1)
+
+
+def test_pick_cycle_passthrough_when_n_equals_len():
+ frames = _periodic_frames(period=4, cycles=2) # 8 帧 == 8
+ assert pick_cycle(frames, 8) is frames
+
+
+def test_pick_cycle_rejects_non_positive_n():
+ """n<=0 两条分支的旧行为都不可接受:检出周期时 IndexError,测不到周期时静默返回 []。"""
+ for frames in (_periodic_frames(period=20, cycles=5), _ramp_frames()):
+ for n in (0, -1):
+ with pytest.raises(ValueError, match="n 必须"):
+ pick_cycle(frames, n)
+
+
+def test_pick_cycle_n1_takes_dominant_pose():
+ """n=1 的"循环"只是一张静止姿势:取全片最具代表性的一帧(停留最久的相位),
+ 而不是首帧 —— i2v 首帧是母版静立姿,单看读不出这是什么动作。"""
+ a = Image.new("RGB", (48, 48), (200, 200, 200))
+ b = Image.new("RGB", (48, 48), (40, 40, 40))
+ frames = [b] + [a] * 20 + [b] * 8 + [a] * 11 # a 占多数,首帧刻意放 b
+ out = pick_cycle(frames, 1)
+ assert len(out) == 1
+ assert out[0] is a
+ assert out[0] is not frames[0]
+
+
+def test_pick_cycle_returns_exactly_n_distinct_frames_for_every_legal_n():
+ """全量扫 n=1..len(frames):两条分支(检出周期 / 测不到周期)都要恒好 n 帧,且互不重复。
+
+ 长度对但夹着重复帧同样是"看起来成功"的错结果 —— 取样相位塌在一起,循环里会卡一下。
+ """
+ for frames in (_periodic_frames(period=8, cycles=5), _ramp_frames()):
+ for n in range(1, len(frames) + 1):
+ out = pick_cycle(frames, n)
+ assert len(out) == n, n
+ pos = [next(i for i, f in enumerate(frames) if f is o) for o in out]
+ assert len(set(pos)) == n, (n, pos)
+
+
+def test_pick_cycle_closes_the_loop():
+ # 取出的一周期,末帧的下一拍应接近首帧(亮度差小)
+ import numpy as np
+
+ frames = _periodic_frames(period=20, cycles=5)
+ out = pick_cycle(frames, 8)
+ first = np.asarray(out[0].convert("L"), float)
+ last = np.asarray(out[-1].convert("L"), float)
+ step = np.abs(np.asarray(out[1].convert("L"), float) - first).mean()
+ seam = np.abs(last - first).mean()
+ assert seam <= step * 2 + 5 # 回接缝不显著大于一个正常步幅
diff --git a/backend/tests/test_master_check_and_quality.py b/backend/tests/test_master_check_and_quality.py
new file mode 100644
index 00000000..33e1f707
--- /dev/null
+++ b/backend/tests/test_master_check_and_quality.py
@@ -0,0 +1,294 @@
+"""母版入口预检 + 出参成色信号。
+
+两头各一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入;
+出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。
+
+2026-08-07 的教训:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错,
+16 帧构图完整的错角色出完、钱花完。而一段每帧都一样的 walk 与一段步态干净的 walk,
+帧数 / 时长 / fps 完全相同,调用方分辨不出。
+"""
+from __future__ import annotations
+
+import io
+
+import pytest
+from PIL import Image
+
+from windup_ai_engine.master_check import (
+ MIN_SUBJECT_SIDE,
+ REJECT_ASPECT,
+ check_master,
+ reject_aspect_for,
+)
+from windup_ai_engine.ports import ActionQuality, MasterRejectCode, MasterRejected
+from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale
+from windup_ai_engine.postprocess.pack import FILL_H, FILL_W
+
+
+def _png(w: int, h: int, blob: tuple[tuple[int, int, int, int], tuple] | None = None,
+ bg=(0, 0, 0, 0)) -> bytes:
+ img = Image.new("RGBA", (w, h), bg)
+ if blob:
+ (x0, y0, x1, y1), color = blob
+ for y in range(y0, y1):
+ for x in range(x0, x1):
+ img.putpixel((x, y), color)
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+# ── 入口预检:四种拒绝码 ──────────────────────────────────────────────────────
+
+
+def test_undecodable_bytes_rejected_before_spending():
+ """坏 bytes 直接炸,不要等 i2v 花完钱才发现输入根本不是图。"""
+ with pytest.raises(MasterRejected) as e:
+ check_master(b"not an image at all")
+ assert e.value.code is MasterRejectCode.UNDECODABLE
+
+
+def test_fully_transparent_has_no_subject():
+ with pytest.raises(MasterRejected) as e:
+ check_master(_png(200, 200))
+ assert e.value.code is MasterRejectCode.NO_SUBJECT
+
+
+def test_flat_single_color_has_no_subject():
+ """全同色 = 没有可动的东西。不透明但一片死板的图同样该拒。"""
+ with pytest.raises(MasterRejected) as e:
+ check_master(_png(200, 200, bg=(120, 90, 60, 255)))
+ assert e.value.code is MasterRejectCode.NO_SUBJECT
+
+
+def test_subject_smaller_than_min_side_rejected():
+ """包围盒最短边不足 → 下游会把它 NEAREST 放大 20 倍,那是色块不是角色。
+
+ 刻意用**细长条**而不是小方块:细长条的像素占比高达 1.3%(远超 0.1% 下限),
+ 所以占比那条拦不住它,只有最短边这条能拦。用小方块的话两条判据都会触发,
+ 删掉任何一条测试都照样绿——那种测试等于没写(2026-08-09 变异测试逮到)。
+ """
+ thin = MIN_SUBJECT_SIDE - 2 # 6px 宽
+ with pytest.raises(MasterRejected) as e:
+ check_master(_png(300, 300, blob=((100, 40, 100 + thin, 240), (200, 60, 60, 255))))
+ assert e.value.code is MasterRejectCode.SUBJECT_TOO_SMALL
+
+
+def test_scattered_specks_pass_side_check_but_fail_area_ratio():
+ """对角两粒噪点会把包围盒撑到整幅——边长检查全过,占比才拦得住。
+
+ 这两条判的不是同一件事,缺了占比这条,一张几乎空白的图会被判成"有主体"。
+ """
+ # 每粒 10×10=100px(边长过得了 MIN_SUBJECT_SIDE=8),两粒共 200px,
+ # 占 600×600 的 0.056%,压在 0.1% 下限之下;而包围盒被撑到 ~590×590,边长检查全过。
+ img = Image.new("RGBA", (600, 600), (0, 0, 0, 0))
+ for (x, y) in ((8, 8), (582, 582)):
+ for dy in range(10):
+ for dx in range(10):
+ img.putpixel((x + dx, y + dy), (200, 60, 60, 255))
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ with pytest.raises(MasterRejected) as e:
+ check_master(buf.getvalue())
+ assert e.value.code is MasterRejectCode.SUBJECT_TOO_SMALL
+
+
+def test_extremely_wide_subject_rejected():
+ """主体太扁 → 方形画布只能把角色硬缩成一条,不如在花钱前退回去。"""
+ w = int(60 * REJECT_ASPECT) + 40
+ with pytest.raises(MasterRejected) as e:
+ check_master(_png(w + 40, 200, blob=((10, 60, 10 + w, 120), (200, 60, 60, 255))))
+ assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE
+
+
+def test_ordinary_humanoid_master_passes_and_reports_facts():
+ """人形母版必须放行——预检的价值在于不误伤,误伤一次比漏放一次更贵。"""
+ facts = check_master(_png(400, 600, blob=((160, 100, 240, 520), (200, 60, 60, 255))))
+ assert facts.size == (400, 600)
+ assert 0.1 < facts.subject_ratio < 1.2
+ assert facts.subject_area_ratio > 0.001
+ assert facts.note() # 进度文案不能是空串
+
+
+def test_reject_aspect_is_derived_from_canvas_geometry_not_hardcoded():
+ """阈值必须跟着画布几何走。把 pack.py 的 FILL_W/FILL_H 改了而这里不动,
+ 预检就会放行一批下游装不下的母版——那正是"看起来成功"的来源。"""
+ assert REJECT_ASPECT == pytest.approx(2 * FILL_W / FILL_H)
+
+
+# ── 非方形交付画布下的比例上限(2026-08-11 挣得)────────────────────────────
+#
+# REJECT_ASPECT 的推导默认画布是方形(FILL_W / FILL_H 是同一条边长的两个比例)。
+# 交付画布可以非方之后前提不再成立:同一条推导做下来是 REJECT_ASPECT*(cw/ch)。
+# 不跟着收的后果是预检按方形判、出帧按非方出 —— 一个刚好过检的主体在 384×512
+# 画布上交付占高只有 0.2324,而这条阈值本意保证的下限是 FILL_H/2=0.31(实测)。
+
+
+def test_reject_aspect_for_square_canvas_is_unchanged():
+ """方形画布(以及不指定)必须与原来完全一致 —— 默认行为不变。"""
+ assert reject_aspect_for(None) == REJECT_ASPECT
+ for c in (128, 256, 512, 1024):
+ assert abs(reject_aspect_for((c, c)) - REJECT_ASPECT) < 1e-12
+
+
+def test_reject_aspect_for_narrow_canvas_tightens_proportionally():
+ """窄高画布容得下的主体更窄,阈值按 cw/ch 收紧;宽扁画布反之放宽。"""
+ assert reject_aspect_for((384, 512)) < REJECT_ASPECT
+ assert reject_aspect_for((512, 384)) > REJECT_ASPECT
+ assert abs(reject_aspect_for((384, 512)) - REJECT_ASPECT * 384 / 512) < 1e-12
+
+
+def test_threshold_delivers_exactly_half_target_height_on_any_canvas():
+ """**预检几何与出帧几何是同一套**的直接证据。
+
+ 阈值的定义就是交付主体高退化到目标高度的一半。拿真实出帧验证:处在各自比例
+ 上限的主体,在任何形状的画布上交付占高都必须落在 FILL_H/2 附近(差的是取整)。
+ """
+ import numpy as np
+
+ from windup_ai_engine.postprocess.pack import align_bottom_center
+
+ for cw, ch in ((256, 256), (512, 512), (384, 512), (512, 384), (128, 192)):
+ limit = reject_aspect_for((cw, ch))
+ base_h, src_w = 200, 3000 # 源画幅给足,别让主体被源边界裁掉
+ blob_w = int(base_h * limit)
+ img = Image.new("RGBA", (src_w, 600), (0, 0, 0, 0))
+ img.paste((200, 60, 60, 255), (100, 100, 100 + blob_w, 100 + base_h))
+ out = align_bottom_center([img], cell=cw, cell_h=ch, ref_height=float(base_h))
+ ys, _ = np.nonzero(np.asarray(out[0])[:, :, 3] > 128)
+ ratio = (int(ys.max()) - int(ys.min()) + 1) / ch
+ assert abs(ratio - FILL_H / 2) < 0.01, (
+ f"{cw}×{ch}: 阈值处交付占高 {ratio:.4f},应为 {FILL_H / 2}"
+ )
+
+
+def test_check_master_uses_the_canvas_it_is_given():
+ """同一张母版:方形画布放行,窄高画布上超限 → 必须被拒。"""
+ ratio = (REJECT_ASPECT + reject_aspect_for((384, 512))) / 2 # 夹在两个阈值中间
+ bw = int(60 * ratio)
+ png = _png(bw + 80, 200, blob=((10, 60, 10 + bw, 120), (200, 60, 60, 255)))
+
+ check_master(png, canvas=(512, 512)) # 方形:放行
+ with pytest.raises(MasterRejected) as e:
+ check_master(png, canvas=(384, 512)) # 窄高:同一张图装不下
+ assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE
+
+
+def test_rejection_carries_machine_readable_code_not_just_a_message():
+ """server 要据此选文案 / 决定 4xx-不重试,用消息做分支会在改文案时悄悄失效。"""
+ with pytest.raises(MasterRejected) as e:
+ check_master(b"broken")
+ assert isinstance(e.value.code, MasterRejectCode)
+ assert e.value.detail
+
+
+# ── 出参成色:三个字段各自不可由其他两个推导 ──────────────────────────────────
+
+
+def _frames(n: int, shift: int = 3) -> list[Image.Image]:
+ out = []
+ for i in range(n):
+ im = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
+ x = 10 + (i * shift) % 30
+ for y in range(20, 50):
+ for xx in range(x, x + 12):
+ im.putpixel((xx, y), (200, 60, 60, 255))
+ out.append(im)
+ return out
+
+
+def test_motion_scale_is_zero_for_a_frozen_sequence():
+ """整段冻结时死帧判据一帧都报不出——两条判据都是相对的,d 全为 0 时
+ `0 < 0` 一条都不成立。绝对尺度必须单独给一个,否则"每帧都一样"这种
+ 最典型的坏产出在出参上完全看不见。"""
+ same = _frames(12, shift=0)
+ assert motion_scale(same) == 0.0
+ assert len(dead_frame_indices(same)) == 0, "相对判据看不见整体没动 —— 正是要 motion_scale 的原因"
+
+
+def test_motion_scale_positive_for_real_movement():
+ assert motion_scale(_frames(12)) > 0.0
+
+
+def test_dead_frame_indices_returns_positions_not_a_mask():
+ """跨出 ai_engine 的契约要"哪几帧",不该让调用方拿 numpy 掩码去 argwhere。"""
+ idx = dead_frame_indices(_frames(10))
+ assert isinstance(idx, tuple)
+ assert all(isinstance(i, int) for i in idx)
+
+
+def test_loop_seam_returns_none_when_there_is_no_step_to_compare():
+ """分母为 0 时返回 None 而不是 0.0——0.0 会被读成"完美闭环",
+ 而真相是"没有可比的步长,这个数不可读"。"""
+ assert loop_seam(_frames(8, shift=0)) is None
+ assert loop_seam(_frames(1)) is None
+
+
+def test_loop_seam_measures_the_gap_between_last_and_first():
+ seam = loop_seam(_frames(10))
+ assert seam is not None and seam >= 0.0
+
+
+def test_quality_fields_are_independent():
+ """三个字段互不可推导:全同帧的 motion_scale=0 而 dead_frames 为空,
+ 两者若能互推,这一组断言不可能同时成立。"""
+ q = ActionQuality(motion_scale=0.0, dead_frames=(), loop_seam=None)
+ assert q.motion_scale == 0.0 and q.dead_frames == () and q.loop_seam is None
+
+
+# ── 分区动量:整幅指标的盲区 ──────────────────────────────────────────────
+
+
+def test_limb_motion_catches_a_frozen_region_that_whole_frame_metrics_miss():
+ """一半肢体冻着、另一半在动 —— motion_scale 与死帧全部正常,只有分区动量看得见。
+
+ 这是自动绑骨漏认一条肢体的典型产物:那块网格没有骨骼驱动,每帧同姿势。
+ """
+ from windup_ai_engine.slicing.quality import (
+ dead_frame_indices,
+ limb_motion,
+ motion_scale,
+ )
+
+ # 左半永远不变,右半逐帧移动
+ frames = []
+ for i in range(12):
+ im = Image.new("RGBA", (64, 96), (0, 0, 0, 0))
+ for y in range(20, 80):
+ for x in range(8, 24): # 左半:固定
+ im.putpixel((x, y), (200, 60, 60, 255))
+ for x in range(40 + (i % 4), 52 + (i % 4)): # 右半:动
+ im.putpixel((x, y), (60, 60, 200, 255))
+ frames.append(im)
+
+ assert motion_scale(frames) > 0.5, "整幅指标应当认为这段在动"
+ assert len(dead_frame_indices(frames)) < len(frames) // 2, "整幅判据也不会报成死帧"
+
+ lm = limb_motion(frames)
+ left = [v for k, v in lm.items() if k.endswith("左") and isinstance(v, float)]
+ right = [v for k, v in lm.items() if k.endswith("右") and isinstance(v, float)]
+ assert max(left) < 0.02, f"冻结的左半占比应当接近 0,实际 {left}"
+ assert min(right) > 0.1, f"在动的右半占比应当显著为正,实际 {right}"
+ assert "左" in lm["still"], f"最静区应当在左半,实际 {lm['still']}"
+
+
+def test_limb_motion_summary_keys_are_not_mistaken_for_regions():
+ """``still`` 只能指向真实分区,不能挑中自己。
+
+ 把汇总键写进字典后才算最小值的话,它(值通常比任何区都小)会被挑成"最静区",
+ 报出一个不存在的区名 —— 一个只在数据上体现、不会报错的错。顺带钉住占比归一。
+ """
+ from windup_ai_engine.slicing.quality import limb_motion
+
+ frames = [Image.new("RGBA", (64, 96), (0, 0, 0, 0)) for _ in range(4)]
+ for i, im in enumerate(frames):
+ for y in range(20, 80):
+ for x in range(10 + i, 30 + i):
+ im.putpixel((x, y), (200, 60, 60, 255))
+ lm = limb_motion(frames)
+ assert lm["still"] != "still", f"still 挑中了汇总键:{lm['still']}"
+ assert lm["still"] in lm and isinstance(lm[lm["still"]], float)
+ shares = [v for k, v in lm.items() if k != "still"]
+ # 容差取舍入精度:各区各自 round 到 3 位,6 个区最多累积 6×0.0005 的误差。
+ # 不写 1e-6 —— 那样断言的是"没做舍入",而不是"归一化对了"。
+ assert abs(sum(shares) - 1.0) < 0.01, f"各区占比应当归一,实际和 {sum(shares)}"
diff --git a/backend/tests/test_master_check_shape_warnings.py b/backend/tests/test_master_check_shape_warnings.py
new file mode 100644
index 00000000..8b32bcba
--- /dev/null
+++ b/backend/tests/test_master_check_shape_warnings.py
@@ -0,0 +1,188 @@
+"""母版预检里**只警告不拒绝**的两条形态判据。
+
+它们对着的是混元图生 3D 的硬约束(四肢要分得开、画面里不得有人体以外的组件),
+而这两条约束违反了**不会报错,只会默默产出错结果** —— 与 ``providers.render3d.checks``
+挡绑骨入口是同一个动作。区别在于:那一层量到就是事实,这一层量到的只是相关信号,
+所以只能摆在母版确认闸上给人看,不能拿来挡路。
+
+合成样本而不是真母版:真母版拿不到"只有腿粘连、其余一模一样"的对照,而这里要证明的
+恰恰是**判据分得开这一对**。
+"""
+from __future__ import annotations
+
+import io
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from windup_ai_engine.master_check import (
+ LIMB_BANDS,
+ MIN_EXTRA_COMPONENT_RATIO,
+ MIN_LIMB_RUN_PX,
+ check_master,
+ component_sizes,
+ limb_segments,
+)
+from windup_ai_engine.ports import MasterWarningCode
+
+INK = (40, 40, 60, 255)
+
+
+def _figure(*, legs_apart: bool, prop: tuple[int, int, int, int] | None = None) -> bytes:
+ """一个正面站立的火柴人。``legs_apart=False`` 时两腿之间的空隙被填死。
+
+ 除了那道空隙(和可选的道具),两张图逐像素相同 —— 判据要是分不开这一对,
+ 它量到的就不是"腿分没分开"。
+ """
+ img = Image.new("RGBA", (200, 400), (0, 0, 0, 0))
+ draw = img.load()
+
+ def block(x0: int, y0: int, x1: int, y1: int) -> None:
+ for y in range(y0, y1):
+ for x in range(x0, x1):
+ draw[x, y] = INK
+
+ block(80, 40, 120, 100) # 头
+ block(70, 100, 130, 240) # 躯干
+ block(40, 110, 70, 130) # 左臂
+ block(130, 110, 160, 130) # 右臂
+ block(74, 240, 94, 380) # 左腿
+ block(106, 240, 126, 380) # 右腿
+ if not legs_apart:
+ block(94, 240, 106, 380) # 把两腿之间的空隙填死
+ if prop is not None:
+ block(*prop)
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def _codes(facts) -> set[MasterWarningCode]:
+ return {w.code for w in facts.warnings}
+
+
+# ── ① 四肢分离 ──────────────────────────────────────────────────────────────
+
+
+def test_legs_apart_measures_two_segments_at_every_band():
+ """双腿分离的人形:四处横切**每一处**都该量到 2 段。
+
+ 只断言"没警告"是不够的 —— 判据恒返回 2 时也没警告。要把量到的数按住。
+ """
+ facts = check_master(_figure(legs_apart=True))
+ assert facts.limb_segments == (2, 2, 2, 2)
+ assert MasterWarningCode.LIMBS_FUSED not in _codes(facts)
+
+
+def test_fused_legs_drop_to_one_segment_and_warn():
+ """空隙被填死后段数掉到 1,且必须出警告 —— 这正是绑骨分不出左右腿的形态。"""
+ facts = check_master(_figure(legs_apart=False))
+ assert facts.limb_segments == (1, 1, 1, 1)
+ assert MasterWarningCode.LIMBS_FUSED in _codes(facts)
+
+
+def test_fused_legs_are_warned_not_rejected():
+ """**不能拒**:侧视角色两腿前后重叠时同样只有 1 段,而侧视正是本项目的主打。
+ 拒了就是拿一条必然误报的判据挡住用户的钱。"""
+ facts = check_master(_figure(legs_apart=False))
+ assert facts.subject_box # 走完了全程、返回了 facts
+ assert facts.warnings # 只是带着警告回来
+
+
+def test_one_occluded_band_does_not_trigger_the_warning():
+ """裙摆/披风只遮住一处时不该报警 —— 判据取四处的最大值,不是每处都要 2 段。"""
+ img = Image.open(io.BytesIO(_figure(legs_apart=True))).convert("RGBA")
+ y = 40 + int(round(LIMB_BANDS[0] * (380 - 40 - 1))) # 主体 y 跨 40..380
+ for dy in range(-3, 4): # 只糊掉第一条带所在的几行
+ for x in range(94, 106):
+ img.putpixel((x, y + dy), INK)
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ facts = check_master(buf.getvalue())
+ assert facts.limb_segments[0] == 1 and max(facts.limb_segments) == 2
+ assert MasterWarningCode.LIMBS_FUSED not in _codes(facts)
+
+
+def test_antialiasing_speckles_do_not_inflate_the_segment_count():
+ """腿外侧的 1px 孤立像素不算一段。不滤掉的话一条腿会被数成三段,
+ "粘连"反而被读成"更分离",判据方向整个反过来。"""
+ mask = np.zeros((10, 40), dtype=bool)
+ mask[:, 4] = True # 1px 宽的毛刺
+ mask[:, 10:20] = True # 真正的一段
+ assert limb_segments(mask, (0, 0, 40, 10)) == (1, 1, 1, 1)
+ assert MIN_LIMB_RUN_PX > 1
+
+
+# ── ② 主体之外的独立色块 ─────────────────────────────────────────────────────
+
+
+def test_lone_figure_is_a_single_component():
+ facts = check_master(_figure(legs_apart=True))
+ assert len(facts.components) == 1
+ assert MasterWarningCode.EXTRA_COMPONENT not in _codes(facts)
+
+
+def test_detached_prop_is_reported_as_an_extra_component():
+ """画面里多一把不挨着身体的剑 → 必须报出来:它会被一起建进网格再绑上权重。"""
+ facts = check_master(_figure(legs_apart=True, prop=(170, 150, 180, 300)))
+ assert len(facts.components) == 2
+ assert MasterWarningCode.EXTRA_COMPONENT in _codes(facts)
+
+
+def test_held_prop_touching_the_body_is_invisible_to_this_check():
+ """**已知盲区,写成用例免得有人当它守住了**:道具与手臂相连时并成一块,零信号。"""
+ facts = check_master(_figure(legs_apart=True, prop=(160, 110, 175, 300)))
+ assert len(facts.components) == 1
+ assert MasterWarningCode.EXTRA_COMPONENT not in _codes(facts)
+
+
+def test_tiny_fragments_are_below_the_reporting_threshold():
+ """抗锯齿碎片不该报警 —— 报警一多就没人看了。"""
+ facts = check_master(_figure(legs_apart=True, prop=(180, 20, 183, 23)))
+ assert len(facts.components) == 1
+ assert MasterWarningCode.EXTRA_COMPONENT not in _codes(facts)
+ assert 0 < MIN_EXTRA_COMPONENT_RATIO < 1
+
+
+def test_components_are_eight_connected():
+ """四邻接会把抗锯齿造成的对角细颈判成断开,同一条手臂被数成两块。"""
+ mask = np.zeros((4, 4), dtype=bool)
+ mask[0, 0] = mask[1, 1] = mask[2, 2] = True
+ assert component_sizes(mask) == (3,)
+
+
+def test_component_sizes_are_ordered_largest_first():
+ mask = np.zeros((6, 20), dtype=bool)
+ mask[1, 1:3] = True
+ mask[1, 10:16] = True
+ assert component_sizes(mask) == (6, 2)
+
+
+# ── ③ 与既有拒绝码的分工 ─────────────────────────────────────────────────────
+
+
+def test_a_detached_prop_cannot_silence_the_fused_legs_warning():
+ """**两条判据不得互相架空。** 一把浮在腿侧的剑会在腿所在的那几行多贡献一段;
+ 若在整幅掩码上数腿,粘连的两腿就被凑够 2 段、警告消失 —— 母版越糟糕反而越安静。
+ 所以数腿只在最大连通块上做。"""
+ facts = check_master(_figure(legs_apart=False, prop=(170, 240, 180, 380)))
+ assert facts.limb_segments == (1, 1, 1, 1)
+ assert _codes(facts) == {
+ MasterWarningCode.LIMBS_FUSED,
+ MasterWarningCode.EXTRA_COMPONENT,
+ }
+ assert all(w.detail for w in facts.warnings)
+
+
+def test_note_mentions_warnings_so_progress_text_is_not_silently_clean():
+ clean = check_master(_figure(legs_apart=True)).note()
+ warned = check_master(_figure(legs_apart=False)).note()
+ assert "警告" not in clean
+ assert "警告" in warned
+
+
+@pytest.mark.parametrize("frac", LIMB_BANDS)
+def test_bands_all_sit_in_the_lower_body(frac: float):
+ """带位必须都在下半身。挪到躯干上去的话,量到的是"腰有没有断开",与腿无关。"""
+ assert 0.5 < frac < 1.0
diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py
new file mode 100644
index 00000000..35e723f4
--- /dev/null
+++ b/backend/tests/test_matte_provider.py
@@ -0,0 +1,515 @@
+"""OnnxU2NetMatteProvider 契约测试(不加载模型 / 不联网:构造 + 协议合规)。"""
+
+import numpy as np
+import pytest
+
+from windup_framework.providers import MatteProvider, OnnxU2NetMatteProvider
+
+
+def test_onnx_matte_satisfies_matte_provider_protocol():
+ # 运行时可检查协议:有 cutout 即满足 MatteProvider(server/ai_engine 依赖此契约)
+ provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx")
+ assert isinstance(provider, MatteProvider)
+ assert callable(provider.cutout)
+
+
+def test_onnx_matte_lazy_no_model_load_on_construct():
+ # 构造不触发下载 / 会话创建(惰性),模型缺失也不报错
+ provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx")
+ assert provider._session is None
+
+
+# ── 底色清理(2026-08-07 实测挣得)────────────────────────────────────────────
+
+
+def _rgb(w, h, bg, blob=None):
+ import numpy as np
+ a = np.zeros((h, w, 3), dtype=np.float32)
+ a[:, :] = bg
+ if blob:
+ (x0, y0, x1, y1), c = blob
+ a[y0:y1, x0:x1] = c
+ return a
+
+
+def test_flat_background_is_killed_but_subject_untouched():
+ """纯色底 → 系数 0(会被清掉);主体色 → 系数 1(一像素不动)。"""
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ bg = (222, 41, 124) # 实测的玫红底
+ fur = (222, 130, 70) # 铁锈橙毛:与底色红通道相同,欧氏距离仅约 104
+ a = _rgb(80, 60, bg, blob=((20, 15, 60, 45), fur))
+ p = _flat_bg_penalty(a)
+ assert p[2, 2] == 0.0, "四角纯背景必须被判为 0"
+ assert p[30, 40] == 1.0, "橙毛必须完全不受影响 —— 宽阈值会把它反解成绿色"
+
+
+def test_enclosed_background_gap_is_killed():
+ """被主体围住的背景空隙也要清掉 —— u2netp 对闭合区域天然失灵。"""
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ bg = (222, 41, 124)
+ a = _rgb(80, 60, bg, blob=((16, 16, 64, 44), (100, 120, 140))) # 避开取样用的 12×12 角落
+ a[24:34, 30:50] = bg # 主体内部挖一个洞,填回底色
+ p = _flat_bg_penalty(a)
+ assert p[30, 40] == 0.0, "闭合空隙里的底色必须被清掉"
+ assert p[20, 20] == 1.0, "洞外的主体不受影响"
+
+
+def test_non_flat_background_disables_cleanup_entirely():
+ """底色不均匀时一律不清理 —— 宁可漏,不可误伤。"""
+ import numpy as np
+
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ rng = np.random.default_rng(0)
+ noisy = rng.uniform(0, 255, (60, 80, 3)).astype(np.float32)
+ assert (_flat_bg_penalty(noisy) == 1.0).all()
+
+
+def test_cleanup_only_subtracts_never_adds_subject():
+ """系数恒在 [0,1] —— 只做减法,最坏情况是少清理,不会凭空造出主体。"""
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ a = _rgb(40, 40, (0, 255, 0), blob=((5, 5, 35, 35), (200, 60, 60)))
+ p = _flat_bg_penalty(a)
+ assert p.min() >= 0.0 and p.max() <= 1.0
+
+
+def test_missing_onnxruntime_raises_instead_of_guessing_background():
+ """装不上就报出来,不能回落到"猜四角主色"——白底浅色角色会被抠穿。"""
+ import builtins
+
+ import pytest
+
+ from windup_framework.providers.matte import OnnxU2NetMatteProvider
+
+ real = builtins.__import__
+
+ def blocked(name, *a, **k):
+ if name == "onnxruntime":
+ raise ImportError("blocked for test")
+ return real(name, *a, **k)
+
+ builtins.__import__ = blocked
+ try:
+ with pytest.raises(RuntimeError, match="onnxruntime"):
+ OnnxU2NetMatteProvider()._get_session()
+ finally:
+ builtins.__import__ = real
+
+
+# ── 视频帧的最外圈是编码器伪影,不是底色(2026-08-10 实测挣得)──────────────
+
+
+def test_edge_artifact_row_does_not_disable_cleanup():
+ """最外一行/列常是编码器伪影:贴边采样会把它算进"底色是否均匀",
+ 于是整帧被判"底不均匀"而跳过清理——修复在真实路径上等于从不生效。
+
+ 实测 9 段真 i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)因此误跳;
+ 往里让 2px 后归零。
+ """
+ import numpy as np
+
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ bg = (222, 41, 124)
+ a = np.zeros((80, 80, 3), dtype=np.float32)
+ a[:, :] = bg
+ a[:, -1] = (0, 0, 0) # 最右一列纯黑:典型的编码器边缘伪影
+ a[0, :] = (180, 30, 100) # 最顶一行偏暗
+ p = _flat_bg_penalty(a)
+ assert p[40, 40] == 0.0, "跳过最外圈后应认出这是纯色底并清理;贴边采样会误判为不均匀"
+
+
+def test_tiny_image_degrades_to_no_cleanup_rather_than_guessing():
+ """图小到四角采样块会盖住主体时,采出来的"底色"其实混了主体色,
+ 此时守卫判"底不均匀"、整体跳过清理。
+
+ 这是**安全的退化方向**:清理只做减法,跳过等于少清一点;反过来若强行按
+ 混了主体色的 key 去清,会把主体本身当背景抠掉——本项目宁可漏,不可误伤。
+ """
+ import numpy as np
+
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ a = np.zeros((20, 20, 3), dtype=np.float32)
+ a[:, :] = (0, 255, 0)
+ a[8:12, 8:12] = (200, 60, 60) # 主体落在四角采样块的重叠区
+ assert (_flat_bg_penalty(a) == 1.0).all(), "采样不可靠时必须整体跳过,而不是按脏 key 清理"
+
+
+# ── 封闭空洞填充(2026-08-11 在 121 帧真实走路视频帧上实测挣得)──────────────────
+#
+# 背景:交付帧放大看,主体内部会有透明洞(背景直接透出来)。实测拆开成因:
+# · u2netp 自身在主体内部造的洞:8 帧抽样里 6 帧为 0 —— 不是主要成因;
+# · 键控误杀:_flat_bg_penalty 每帧杀掉 820~2346 个 u2netp 判为主体的像素 ——
+# 浅肤色 (243,221,200) 到灰底 (219,219,220) 的欧氏距离只有 31.3,窄于 _KEY_KILL=38。
+# 这些被误杀的像素被主体围住,正是"封闭空洞",填回来即修复。
+#
+# 但**只按"不与画面边界连通"判定会把两腿之间填实**:迈步相里两只靴子在下方交叠,
+# 把腿间空隙彻底封死。实测 121 帧中 80 帧存在这种封闭的底色空隙,共 25173 像素;
+# 朴素版(只判连通性)把这 25173 像素全部填成主体(最惨单帧 3172 像素,两腿焊死),
+# 加了颜色守卫后填掉 0 像素。下面的用例把这条守住。
+
+
+def _walk_frame(*, gap_closed: bool, hole: bool = False, eroded_leg: bool = False):
+ """造一帧"迈步相":灰底 + 躯干 + 两条腿 + 腿间底色空隙。
+
+ ``gap_closed=True`` 时靴子在下方交叠、把腿间空隙封死(实测 80/121 帧是这形状)。
+ 颜色取实测值:底 (219,219,220)、浅肤 (243,221,200)(两者距离 31.3,窄于 _KEY_KILL)。
+ 返回 (rgb float32, alpha float32)。
+ """
+ import numpy as np
+
+ bg, skin, cloth = (219, 219, 220), (243, 221, 200), (110, 130, 100)
+ h, w = 64, 64
+ rgb = np.full((h, w, 3), bg, dtype=np.float32)
+ alpha = np.zeros((h, w), dtype=np.float32)
+
+ def paint(y0, y1, x0, x1, color):
+ rgb[y0:y1, x0:x1] = color
+ alpha[y0:y1, x0:x1] = 1.0
+
+ paint(8, 32, 20, 44, cloth) # 躯干
+ paint(32, 52, 20, 28, skin) # 后腿
+ paint(32, 52, 36, 44, skin) # 前腿
+ if gap_closed:
+ paint(52, 58, 20, 44, cloth) # 靴子交叠 → 腿间空隙被封死
+ else:
+ paint(52, 58, 20, 28, cloth) # 两只靴子分开 → 空隙通到画面底边
+ paint(52, 58, 36, 44, cloth)
+ if hole:
+ alpha[14:20, 28:36] = 0.0 # 躯干内部的洞:颜色还是衣服色
+ if eroded_leg:
+ alpha[36:46, 22:26] = 0.0 # 腿内部被键控误杀的一条:颜色是浅肤色
+ return rgb, alpha
+
+
+def _gap_slice():
+ """腿间空隙区域(rgb 一直是底色,alpha 一直应为 0)。"""
+ return (slice(32, 52), slice(28, 36))
+
+
+def test_enclosed_hole_in_subject_is_filled():
+ """被主体围住、颜色不是底色的透明块 = 洞,填成主体。"""
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=True, hole=True)
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert (out[14:20, 28:36] == 1.0).all(), "躯干内部的洞必须被填成主体"
+
+
+def test_keyed_out_skin_inside_leg_is_filled():
+ """被 _flat_bg_penalty 误杀的浅肤色(实测每帧 820~2346 px)要能填回来。"""
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=True, eroded_leg=True)
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert (out[36:46, 22:26] == 1.0).all(), "浅肤色距底色 31.3,不是底色,必须填回主体"
+
+
+def test_closed_leg_gap_is_never_filled():
+ """**核心回归**:靴子交叠把腿间空隙封死时,它照样不能被填 —— 否则两腿焊在一起。
+
+ 实测:只判"不与边界连通"的朴素版在这里会把整块空隙填掉(121 帧共 25173 px)。
+ """
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=True)
+ ys, xs = _gap_slice()
+ assert (alpha[ys, xs] == 0.0).all(), "前提:空隙本来是透明的"
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert (out[ys, xs] == 0.0).all(), "腿间空隙整块是底色,一个像素都不能填"
+
+
+def test_open_leg_gap_is_never_filled():
+ """空隙通到画面底边时同样不能填 —— 这条也钉死"绝不能按行/按列填"。
+
+ 按行填会看到"这一行左右都是主体"就把中间填上,正是这里要拦的。
+ """
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=False)
+ ys, xs = _gap_slice()
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert (out[ys, xs] == 0.0).all(), "与边界连通的空隙不是洞"
+ assert (out == alpha).all(), "没有洞的帧必须逐像素不变"
+
+
+def test_border_touching_transparent_area_is_never_filled():
+ """贴着画幅边缘的透明区域不是洞 —— 哪怕它的颜色一点也不像底色。
+
+ 真实场景:i2v 出的帧经常把角色下半身裁出画,两腿之间是一条暗投影(不是干净底色),
+ 这条投影只从画幅下沿通向画外,左右被两条腿封死。只靠"颜色像不像底色"判断会把
+ 它当成洞、填成主体(两腿又焊上了),所以"从边界出发"这条种子必须保留。
+ """
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=False)
+ for x0, x1 in ((20, 28), (36, 44)): # 两条腿一直延到画幅下沿
+ rgb[52:64, x0:x1] = (110, 130, 100)
+ alpha[52:64, x0:x1] = 1.0
+ rgb[32:64, 28:36] = (60, 55, 50) # 腿间暗投影:远离底色
+ alpha[32:64, 28:36] = 0.0 # 只从下沿通向画外,左右被腿封死
+
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert (out[32:64, 28:36] == 0.0).all(), "连到画幅边界的透明区域一律不是洞"
+
+
+def test_frame_without_holes_is_pixel_identical():
+ """没有洞 → 逐像素不变(防回归硬指标)。"""
+ import numpy as np
+
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=True)
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert np.array_equal(out, alpha)
+
+
+def test_fill_only_adds_alpha_never_removes():
+ """只做加法:alpha 绝不被改小,改动值只能是 1.0 —— 填洞不该顺手抠掉别的。"""
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=True, hole=True, eroded_leg=True)
+ out = _fill_enclosed_holes(alpha, rgb)
+ assert (out >= alpha).all()
+ assert (out[out != alpha] == 1.0).all()
+
+
+def test_non_flat_background_disables_fill_entirely():
+ """底色不均匀 → 无从判断哪块是真空隙,一律不填(与键控清理同一条纪律)。"""
+ import numpy as np
+
+ from windup_framework.providers.matte import _fill_enclosed_holes
+
+ rgb, alpha = _walk_frame(gap_closed=True, hole=True)
+ rng = np.random.default_rng(0)
+ noisy = rng.uniform(0, 255, rgb.shape).astype(np.float32)
+ out = _fill_enclosed_holes(alpha, noisy)
+ assert np.array_equal(out, alpha), "底不是纯色时必须整帧不动"
+
+
+def test_bg_key_returns_none_when_background_is_not_flat():
+ """底色真相源:不均匀时返回 None,键控与填洞都据此停手。"""
+ import numpy as np
+
+ from windup_framework.providers.matte import _bg_key
+
+ rng = np.random.default_rng(1)
+ assert _bg_key(rng.uniform(0, 255, (40, 40, 3)).astype(np.float32)) is None
+ assert _bg_key(np.full((40, 40, 3), 219, dtype=np.float32)) is not None
+
+
+def test_spread_is_four_connected_not_scanline():
+ """扩散必须是真 4-邻接连通:L 形走廊要能拐弯走通,断开的孤岛不能被沾到。
+
+ 只做行传播(或只做列传播)都会让 L 形的另一条臂走不通,这条用例把两个方向都钉死。
+ """
+ import numpy as np
+
+ from windup_framework.providers.matte import _spread
+
+ region = np.zeros((20, 20), dtype=bool)
+ region[2, 2:18] = True # 横臂
+ region[2:18, 17] = True # 竖臂(拐弯)
+ island = (15, 3)
+ region[island] = True # 孤岛:与走廊不连通
+ seed = np.zeros_like(region)
+ seed[2, 2] = True
+
+ reach = _spread(seed, region)
+ assert reach[2, 17], "横臂尽头要走通(需要行传播)"
+ assert reach[17, 17], "竖臂尽头要走通(需要列传播)"
+ assert not reach[island], "不连通的孤岛绝不能被标记为可达"
+
+
+def test_spread_matches_bruteforce_bfs_on_random_masks():
+ """与逐像素 BFS 逐点等价 —— 向量化只是为了快,不能改语义。"""
+ from collections import deque
+
+ import numpy as np
+
+ from windup_framework.providers.matte import _spread
+
+ def bfs(seed, region):
+ h, w = region.shape
+ out = np.zeros_like(region)
+ q = deque()
+ for y, x in zip(*np.nonzero(seed & region), strict=True):
+ out[y, x] = True
+ q.append((y, x))
+ while q:
+ cy, cx = q.popleft()
+ for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
+ ny, nx = cy + dy, cx + dx
+ if 0 <= ny < h and 0 <= nx < w and region[ny, nx] and not out[ny, nx]:
+ out[ny, nx] = True
+ q.append((ny, nx))
+ return out
+
+ rng = np.random.default_rng(7)
+ for _ in range(25):
+ h, w = int(rng.integers(3, 30)), int(rng.integers(3, 30))
+ region = rng.random((h, w)) < rng.uniform(0.3, 0.9)
+ seed = rng.random((h, w)) < 0.05
+ assert (_spread(seed, region) == bfs(seed, region)).all()
+
+
+# ── cutout 的装配顺序(不碰真模型)─────────────────────────────────────────
+#
+# 真实推理需要 4.7MB 的 onnx 权重,CI 里既下不到也不该下。但 cutout 本身的**装配顺序**
+# 是有语义的,可以用一个假 session 覆盖:
+# 预测 mask → 乘键控清理系数 → 填封闭空洞 → 合成 RGBA
+# 顺序错了会静默出错结果:先填洞再清理,会把刚填上的像素又清掉。
+
+
+class _FakeSession:
+ """假 onnxruntime session:返回一个中间为主体的 mask。"""
+
+ class _In:
+ name = "input"
+
+ def get_inputs(self):
+ return [self._In()]
+
+ def run(self, _out, feed):
+ import numpy as np
+
+ t = next(iter(feed.values()))
+ h, w = t.shape[2], t.shape[3]
+ m = np.zeros((1, 1, h, w), dtype="float32")
+ m[:, :, h // 4 : h * 3 // 4, w // 4 : w * 3 // 4] = 1.0
+ return [m]
+
+
+def _provider_with_fake_session(monkeypatch):
+ from windup_framework.providers.matte import OnnxU2NetMatteProvider
+
+ p = OnnxU2NetMatteProvider()
+ monkeypatch.setattr(p, "_get_session", lambda: _FakeSession())
+ return p
+
+
+def _png(w=64, h=64, color=(220, 220, 220)):
+ import io
+
+ from PIL import Image
+
+ buf = io.BytesIO()
+ Image.new("RGB", (w, h), color).save(buf, "PNG")
+ return buf.getvalue()
+
+
+def test_cutout_outputs_rgba_png_with_alpha(monkeypatch):
+ import io
+
+ from PIL import Image
+
+ out = _provider_with_fake_session(monkeypatch).cutout(_png())
+ im = Image.open(io.BytesIO(out))
+ assert im.format == "PNG" and im.mode == "RGBA"
+ assert im.size == (64, 64)
+
+
+def test_cutout_keeps_rgb_untouched(monkeypatch):
+ """抠图只动 alpha。改 RGB 会让后续像素化锁色板取到被改过的颜色。"""
+ import io
+
+ import numpy as np
+ from PIL import Image
+
+ src = _png(color=(31, 41, 59))
+ out = _provider_with_fake_session(monkeypatch).cutout(src)
+ a = np.asarray(Image.open(io.BytesIO(out)))
+ b = np.asarray(Image.open(io.BytesIO(src)).convert("RGB"))
+ assert np.array_equal(a[:, :, :3], b), "RGB 通道被改了"
+
+
+def test_cutout_applies_flat_bg_cleanup_before_filling_holes(monkeypatch):
+ """顺序:清理 → 填洞。反过来会把刚填上的像素又清掉,且不报错。
+
+ 用调用顺序断言而不是像素结果 —— 结果层面两种顺序在简单图上可能相同,
+ 那样的用例杀不掉顺序颠倒这个变异。
+ """
+ import windup_framework.providers.matte as M
+
+ order: list[str] = []
+ real_pen, real_fill = M._flat_bg_penalty, M._fill_enclosed_holes
+ monkeypatch.setattr(M, "_flat_bg_penalty",
+ lambda rgb: (order.append("clean"), real_pen(rgb))[1])
+ monkeypatch.setattr(M, "_fill_enclosed_holes",
+ lambda a, rgb: (order.append("fill"), real_fill(a, rgb))[1])
+ _provider_with_fake_session(monkeypatch).cutout(_png())
+ assert order == ["clean", "fill"], order
+
+
+# ── 键控清理不得抠穿主体 ────────────────────────────────────────────────────
+#
+# 固定阈值 38 会杀掉与底色距离小于它的**主体内部**像素:实测五个真实角色母版上,
+# 用 u2netp 掩码判主体,受损比例最高到 34.4%(美少女)、10.2%(钟表匠)。深色角色距离
+# 300 以上,永远不沾这个窗口 —— 所以症状只出现在浅色角色身上。
+
+
+def _synth(bg, body, size=96):
+ """整幅 bg 底色,中间一块 body 色的主体。"""
+ img = np.full((size, size, 3), bg, dtype=np.float32)
+ img[28:76, 32:64] = body
+ return img
+
+
+@pytest.mark.parametrize("name,bg,body", [
+ ("骨白角色白底", (250, 250, 250), (238, 236, 228)),
+ ("浅灰铠甲白底", (248, 248, 248), (226, 226, 224)),
+ ("浅肤色灰白底", (235, 235, 232), (241, 214, 196)),
+ ("米白布料白底", (250, 250, 250), (232, 228, 215)),
+])
+def test_light_subject_is_not_keyed_through(name, bg, body):
+ """浅色主体不得被键控清理削掉,一个像素都不行。"""
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ core = _flat_bg_penalty(_synth(bg, body))[28:76, 32:64]
+ assert (core == 1.0).all(), f"{name}: {int((core < 1.0).sum())} 个主体像素被削"
+
+
+def test_dark_subject_unaffected():
+ """深色主体本来就不受影响,改动不该改变它。"""
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ core = _flat_bg_penalty(_synth((250, 250, 250), (60, 55, 70)))[28:76, 32:64]
+ assert (core == 1.0).all()
+
+
+@pytest.mark.parametrize("noise", [0.0, 3.0])
+def test_enclosed_gap_still_cleaned(noise):
+ """清理能力不得回退:被主体围住的底色空隙仍要被清掉。
+
+ 这是 _flat_bg_penalty 存在的理由 —— u2netp 对闭合区域失灵,四足腿间的背景会被
+ 当成主体内部整块留下。窄半径不能把这个能力一起窄掉。
+ """
+ from windup_framework.providers.matte import _flat_bg_penalty
+
+ rng = np.random.default_rng(7)
+ img = np.full((96, 96, 3), (250, 250, 250), dtype=np.float32)
+ if noise:
+ img += rng.normal(0, noise, img.shape)
+ img[20:80, 24:72] = (60, 55, 70) # 主体
+ img[40:60, 40:56] = (250, 250, 250) # 主体内部的底色空隙
+ if noise:
+ img[40:60, 40:56] += rng.normal(0, noise, (20, 16, 3))
+
+ gap = _flat_bg_penalty(img)[40:60, 40:56]
+ assert (gap == 0.0).mean() > 0.9, "闭合空隙没被清掉,清理能力回退了"
+
+
+def test_kill_radius_follows_background_noise():
+ """半径随底噪走:干净底取下限,噪声底自动放宽。"""
+ from windup_framework.providers.matte import _kill_radius, _KEY_KILL_MIN, _KEY_KILL_MAX
+
+ rng = np.random.default_rng(3)
+ clean = np.full((96, 96, 3), 250.0, dtype=np.float32)
+ noisy = clean + rng.normal(0, 4.0, clean.shape)
+
+ assert _kill_radius(clean) == _KEY_KILL_MIN
+ assert _KEY_KILL_MIN < _kill_radius(noisy) <= _KEY_KILL_MAX
diff --git a/backend/tests/test_media_upload.py b/backend/tests/test_media_upload.py
new file mode 100644
index 00000000..efb0d392
--- /dev/null
+++ b/backend/tests/test_media_upload.py
@@ -0,0 +1,279 @@
+"""media 上传 API 测试。
+
+覆盖:大小限制、magic bytes 校验、content_type 白名单、异步上传。
+"""
+
+from unittest.mock import patch
+
+from windup_common.enums.biz_code import BizCode
+
+from windup_app.server.media.model import MediaUploadResult
+from windup_app.web.api.media import (
+ _ALLOWED_IMAGE_TYPES,
+ _ALLOWED_MODEL_TYPES,
+ _ALLOWED_TYPES,
+ _get_size_limit,
+ _validate_image_magic,
+)
+
+
+# -- Magic bytes 校验测试 --------------------------------------------------
+
+
+def test_validate_image_magic_png():
+ """PNG 文件头校验通过。"""
+ png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
+ assert _validate_image_magic(png_header, "image/png") is True
+
+
+def test_validate_image_magic_jpeg():
+ """JPEG 文件头校验通过。"""
+ jpeg_header = b"\xff\xd8\xff\xe0" + b"\x00" * 100
+ assert _validate_image_magic(jpeg_header, "image/jpeg") is True
+
+
+def test_validate_image_magic_gif():
+ """GIF 文件头校验通过(GIF87a 和 GIF89a)。"""
+ gif87a = b"GIF87a" + b"\x00" * 100
+ gif89a = b"GIF89a" + b"\x00" * 100
+ assert _validate_image_magic(gif87a, "image/gif") is True
+ assert _validate_image_magic(gif89a, "image/gif") is True
+
+
+def test_validate_image_magic_webp():
+ """WebP 文件头校验通过(RIFF + WEBP at bytes 8-11)。"""
+ webp_header = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 100
+ assert _validate_image_magic(webp_header, "image/webp") is True
+
+
+def test_validate_image_magic_webp_rejects_riff_without_webp():
+ """仅有 RIFF 前缀但无 WEBP 标记的文件被拒绝(如 WAV、AVI)。"""
+ wav_header = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 100
+ assert _validate_image_magic(wav_header, "image/webp") is False
+
+ avi_header = b"RIFF\x00\x00\x00\x00AVI " + b"\x00" * 100
+ assert _validate_image_magic(avi_header, "image/webp") is False
+
+
+def test_validate_image_magic_webp_rejects_short_data():
+ """数据不足 12 字节的 WebP 被拒绝。"""
+ short_data = b"RIFF\x00\x00"
+ assert _validate_image_magic(short_data, "image/webp") is False
+
+
+def test_validate_image_magic_mismatch():
+ """文件头与声明的 content_type 不匹配。"""
+ jpeg_header = b"\xff\xd8\xff\xe0" + b"\x00" * 100
+ assert _validate_image_magic(jpeg_header, "image/png") is False
+
+
+def test_validate_image_magic_rejects_unknown_type():
+ """不在白名单中的图片子类型直接拒绝。"""
+ data = b"\x00" * 100
+ assert _validate_image_magic(data, "image/svg+xml") is False
+ assert _validate_image_magic(data, "image/bmp") is False
+
+
+# -- 大小限制测试 ----------------------------------------------------------
+
+
+def test_size_limit_image():
+ """图片限制 10 MB。"""
+ assert _get_size_limit("image/png") == 10 * 1024 * 1024
+ assert _get_size_limit("image/jpeg") == 10 * 1024 * 1024
+ assert _get_size_limit("image/gif") == 10 * 1024 * 1024
+ assert _get_size_limit("image/webp") == 10 * 1024 * 1024
+
+
+def test_size_limit_model():
+ """3D 模型限制 80 MB。"""
+ assert _get_size_limit("model/gltf-binary") == 80 * 1024 * 1024
+ assert _get_size_limit("model/gltf+json") == 80 * 1024 * 1024
+
+
+def test_size_limit_unknown_defaults_to_image():
+ """未知类型默认使用图片限制 10 MB。"""
+ assert _get_size_limit("application/octet-stream") == 10 * 1024 * 1024
+
+
+# -- 白名单配置测试 --------------------------------------------------------
+
+
+def test_allowed_types_covers_all_image_subtypes():
+ """所有允许的图片子类型都有对应的 magic bytes 校验。"""
+ for mime in _ALLOWED_IMAGE_TYPES:
+ assert mime in _validate_image_magic.__code__.co_consts or \
+ mime in {"image/png", "image/jpeg", "image/gif", "image/webp"}
+
+
+def test_allowed_types_disjoint():
+ """图片和模型类型集合不重叠。"""
+ assert _ALLOWED_IMAGE_TYPES.isdisjoint(_ALLOWED_MODEL_TYPES)
+
+
+def test_allowed_types_is_union():
+ """_ALLOWED_TYPES 是图片和模型的并集。"""
+ assert _ALLOWED_TYPES == _ALLOWED_IMAGE_TYPES | _ALLOWED_MODEL_TYPES
+
+
+# -- 端点测试(通过 TestClient + auth_client)-----------------------------
+
+MOCK_RESULT = MediaUploadResult(
+ url="https://cdn.example.com/media/test.png",
+ object_key="media/general/abc123.png",
+ filename="test.png",
+ content_type="image/png",
+ size=1024,
+)
+
+
+def _make_png_bytes(size: int = 1024) -> bytes:
+ """构造合法 PNG 头 + 填充到指定大小。"""
+ return b"\x89PNG\r\n\x1a\n" + b"\x00" * (size - 8)
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_success(mock_service, auth_client):
+ """正常上传图片返回 URL。"""
+ mock_service.upload.return_value = MOCK_RESULT
+ png_data = _make_png_bytes(512)
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("test.png", png_data, "image/png")},
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == BizCode.SUCCESS
+ assert body["data"]["url"] == MOCK_RESULT.url
+ mock_service.upload.assert_called_once()
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_model_type_allowed(mock_service, auth_client):
+ """model/gltf-binary 类型允许上传。"""
+ mock_service.upload.return_value = MOCK_RESULT.model_copy(
+ update={"content_type": "model/gltf-binary", "filename": "avatar.glb"}
+ )
+ glb_data = b"glTF" + b"\x00" * 100
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("avatar.glb", glb_data, "model/gltf-binary")},
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["code"] == BizCode.SUCCESS
+
+
+def test_upload_rejects_unsupported_type(auth_client):
+ """非白名单类型被拒绝。"""
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("malware.exe", b"MZ\x90\x00", "application/octet-stream")},
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] != BizCode.SUCCESS
+ assert "不支持的文件类型" in body["message"]
+
+
+def test_upload_rejects_image_subtype_not_in_whitelist(auth_client):
+ """白名单外的 image/* 子类型被拒绝(如 image/svg+xml)。"""
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("icon.svg", b"", "image/svg+xml")},
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] != BizCode.SUCCESS
+ assert "不支持的文件类型" in body["message"]
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_rejects_oversized_image(mock_service, auth_client):
+ """图片超过 10 MB 被拒绝。"""
+ over_size = 10 * 1024 * 1024 + 1
+ png_data = _make_png_bytes(over_size)
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("big.png", png_data, "image/png")},
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] != BizCode.SUCCESS
+ assert "超过限制" in body["message"]
+ mock_service.upload.assert_not_called()
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_accepts_image_at_limit(mock_service, auth_client):
+ """图片恰好 10 MB 可以上传。"""
+ mock_service.upload.return_value = MOCK_RESULT
+ png_data = _make_png_bytes(10 * 1024 * 1024)
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("max.png", png_data, "image/png")},
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["code"] == BizCode.SUCCESS
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_rejects_magic_mismatch(mock_service, auth_client):
+ """声称 PNG 但文件头是 JPEG → 拒绝。"""
+ jpeg_data = b"\xff\xd8\xff\xe0" + b"\x00" * 100
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("fake.png", jpeg_data, "image/png")},
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] != BizCode.SUCCESS
+ assert "文件内容与声明的类型不匹配" in body["message"]
+ mock_service.upload.assert_not_called()
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_no_magic_check_for_model(mock_service, auth_client):
+ """model/* 类型不做 magic bytes 校验。"""
+ mock_service.upload.return_value = MOCK_RESULT.model_copy(
+ update={"content_type": "model/gltf-binary", "filename": "scene.glb"}
+ )
+ random_data = b"\x00\x01\x02\x03" + b"\x00" * 100
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("scene.glb", random_data, "model/gltf-binary")},
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["code"] == BizCode.SUCCESS
+
+
+@patch("windup_app.web.api.media.service")
+def test_upload_service_biz_error(mock_service, auth_client):
+ """service.upload 抛 BizException 时返回对应错误码。"""
+ from windup_common.exceptions import BizException
+
+ mock_service.upload.side_effect = BizException("存储空间不足", code=BizCode.INTERNAL_ERROR)
+ png_data = _make_png_bytes(512)
+
+ resp = auth_client.post(
+ "/media/upload",
+ files={"file": ("test.png", png_data, "image/png")},
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["code"] == BizCode.INTERNAL_ERROR
+ assert "存储空间不足" in body["message"]
diff --git a/backend/tests/test_oneshot.py b/backend/tests/test_oneshot.py
new file mode 100644
index 00000000..2307a030
--- /dev/null
+++ b/backend/tests/test_oneshot.py
@@ -0,0 +1,177 @@
+"""一次性动作抽帧(裁动作区间 / 跳跃状态切段)测试 —— 纯 CV,无需联网。"""
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from windup_ai_engine.slicing import (
+ find_motion_span,
+ first_action_end,
+ foot_line_series,
+ pick_oneshot,
+ split_jump_phases,
+)
+
+
+def _figure_at(y_bottom: int, size: int = 64, h: int = 20) -> Image.Image:
+ """在指定底边高度画一个方块"角色"(RGBA,其余透明)。"""
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ arr = np.asarray(img).copy()
+ top = max(0, y_bottom - h)
+ arr[top:y_bottom, size // 2 - 4 : size // 2 + 4] = (200, 60, 60, 255)
+ return Image.fromarray(arr, "RGBA")
+
+
+def _jump_sequence() -> list[Image.Image]:
+ """合成跳跃:静止 → 蹲(底边下移)→ 升 → 顶点 → 落 → 静止。"""
+ ground, low, apex = 50, 52, 30
+ ys = [ground] * 3 + [low, low] + [44, 38, apex, apex, 38, 44] + [ground] * 3
+ return [_figure_at(y) for y in ys]
+
+
+def test_find_motion_span_trims_static_head_and_tail():
+ frames = _jump_sequence()
+ start, end = find_motion_span(frames)
+ assert start >= 1 # 前面的静止帧被裁掉
+ assert end <= len(frames) - 2 # 后面的静止帧被裁掉
+ assert end > start
+
+
+def test_pick_oneshot_returns_n_and_does_not_wrap():
+ frames = _jump_sequence()
+ out = pick_oneshot(frames, 6)
+ assert len(out) == 6
+ # 一次性动作不闭环:首尾姿态应不同(闭环的话会几乎一样)
+ first = np.asarray(out[0].convert("L"), float)
+ last = np.asarray(out[-1].convert("L"), float)
+ assert np.abs(first - last).mean() >= 0
+
+
+def test_foot_line_tracks_height():
+ frames = _jump_sequence()
+ y = foot_line_series(frames)
+ assert y.argmin() in range(6, 10) # 最高点(y 最小)落在顶点附近
+ assert y[0] > y.min() # 起始在地面,低于顶点
+
+
+def test_split_jump_phases_covers_all_frames_in_order():
+ frames = _jump_sequence()
+ phases = split_jump_phases(frames)
+ assert "apex" in phases
+ idx = [i for seg in phases.values() for i in seg]
+ assert sorted(idx) == list(range(len(frames))) # 不重不漏
+ # apex 段应在 rise 之后、fall 之前
+ if "rise" in phases and "fall" in phases:
+ assert max(phases["rise"]) < min(phases["apex"])
+ assert max(phases["apex"]) < min(phases["fall"])
+
+
+def test_split_jump_phases_short_input_is_safe():
+ assert split_jump_phases([_figure_at(50)] * 3)
+
+
+# ── 入参边界 ────────────────────────────────────────────────────────────────
+# 契约:返回长度恒等于 n;凡是给不出 n 帧的入参一律报错,绝不静默少给。
+
+
+def _bar_at(x: int, w: int = 8, size: int = 64) -> Image.Image:
+ """横向位移的方块,用来造挥击序列。位移刻意都 < 条宽 → 像素差与位移成正比、不饱和。"""
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ arr = np.asarray(img).copy()
+ arr[20:50, x : x + w] = (200, 60, 60, 255)
+ return Image.fromarray(arr, "RGBA")
+
+
+def _swing_sequence() -> list[Image.Image]:
+ """合成挥击:静止 → 加速横扫(最快的一跳是 16→22)→ 收势 → 静止。"""
+ xs = [10] * 4 + [11, 13, 16, 22, 25, 26] + [26] * 4
+ return [_bar_at(x) for x in xs]
+
+
+def _tail_action_sequence() -> list[Image.Image]:
+ """动作贴在尾部(视频在半空结束,没有静止收尾)—— 区间放宽时右边无处可长,
+ 必须把缺口退回左边,否则窗口不足 n 帧、只能靠重复帧凑数。"""
+ ys = [50] * 8 + [52, 52, 44, 38, 30, 30, 38, 44]
+ return [_figure_at(y) for y in ys]
+
+
+def test_pick_oneshot_n1_takes_apex_for_airborne():
+ """n=1 取关键姿势:腾空类应给顶点帧,而不是首帧(蓄力,和待机一个样)。"""
+ frames = _jump_sequence()
+ apex = int(np.argmin(foot_line_series(frames)))
+ out = pick_oneshot(frames, 1, kind="airborne")
+ assert len(out) == 1
+ assert out[0] is frames[apex]
+
+
+def test_pick_oneshot_n1_takes_impact_for_swing():
+ """n=1 取关键姿势:挥击类应给"刚走完最快一跳"的命中帧,不是首帧/末帧。"""
+ frames = _swing_sequence()
+ out = pick_oneshot(frames, 1)
+ assert len(out) == 1
+ assert out[0] is frames[7] # 16→22 是最快的一跳,落点即命中姿势
+ assert out[0] is not frames[0] and out[0] is not frames[-1]
+
+
+def test_pick_oneshot_rejects_non_positive_n():
+ """n<=0 原本静默返回 [](零帧也算"成功"),现在必须报错。"""
+ frames = _jump_sequence()
+ for n in (0, -1):
+ with pytest.raises(ValueError, match="n 必须"):
+ pick_oneshot(frames, n)
+
+
+def test_pick_oneshot_rejects_insufficient_source():
+ """源帧不够 n 帧:报错并把两个数字都说清楚,不再原样返回一个短序列。"""
+ frames = _jump_sequence()
+ with pytest.raises(ValueError, match=r"源帧不足.*19.*14"):
+ pick_oneshot(frames, len(frames) + 5)
+ with pytest.raises(ValueError, match="源帧不足"):
+ pick_oneshot([], 4)
+ with pytest.raises(ValueError, match="源帧不足"):
+ pick_oneshot([_figure_at(50)], 2)
+
+
+def test_pick_oneshot_returns_exactly_n_for_every_legal_n():
+ """全量扫 n=1..len(frames):长度必须恒等于 n。
+
+ 修前 pick_oneshot(jump14, 12) 只回 9 帧 —— 动作区间被裁到 9 帧后直接原样返回,
+ 而下游 frame_durations 按实际长度现算时长,帧数与时长自洽,谁都看不出少了 3 帧。
+ """
+ for frames in (_jump_sequence(), _swing_sequence(), _tail_action_sequence()):
+ for kind in ("swing", "airborne"):
+ for n in range(1, len(frames) + 1):
+ assert len(pick_oneshot(frames, n, kind=kind)) == n, (kind, n)
+ for n in range(1, len(frames) + 1): # first_only=False 走另一条区间分支
+ assert len(pick_oneshot(frames, n, first_only=False)) == n
+
+
+def test_pick_oneshot_passthrough_when_n_equals_len():
+ frames = _jump_sequence()
+ assert pick_oneshot(frames, len(frames)) is frames
+
+
+def test_pick_oneshot_frames_are_distinct_source_frames_in_order():
+ """源帧够 n 张时,返回的 n 帧必须**互不相同**且时间顺序不倒。
+
+ 长度对但夹着重复帧,是另一种"看起来成功"的错结果:时长表照样自洽,播出来是卡顿。
+ 动作贴尾部的序列是这条的关键用例 —— 区间往右长不动,缺口只能退回左边补。
+ """
+ for frames in (_jump_sequence(), _swing_sequence(), _tail_action_sequence()):
+ for n in range(1, len(frames) + 1):
+ out = pick_oneshot(frames, n, kind="airborne")
+ pos = [next(i for i, f in enumerate(frames) if f is o) for o in out]
+ assert pos == sorted(pos), (n, pos)
+ assert len(set(pos)) == n, (n, pos) # 无重复帧
+
+
+def test_unknown_kind_is_rejected():
+ """kind 拼错不能静默按 swing 处理 —— 判据用错会裁出"看起来对"的错区间。"""
+ frames = _jump_sequence()
+ with pytest.raises(ValueError, match="kind"):
+ pick_oneshot(frames, 6, kind="airbourne")
+ with pytest.raises(ValueError, match="kind"):
+ # first_only=False 不走 first_action_end,得靠 pick_oneshot 自己那道校验
+ pick_oneshot(frames, 6, kind="airbourne", first_only=False)
+ with pytest.raises(ValueError, match="kind"):
+ first_action_end(frames, 0, 1, kind="airbourne") # 短区间(早返回)也要拦住
diff --git a/backend/tests/test_orchestrator_hardening.py b/backend/tests/test_orchestrator_hardening.py
new file mode 100644
index 00000000..cc8ae6cc
--- /dev/null
+++ b/backend/tests/test_orchestrator_hardening.py
@@ -0,0 +1,594 @@
+"""编排层的加固用例。
+
+EventBus 的键是 ``(project_id, task_id)``(主线 #110:同一 task_id 在不同项目下互不
+串流)。本文件里的 project_id 取一个固定值即可 —— 这些用例验的是队列/loop 行为,
+项目隔离本身由 test_generation_api.py 的专用用例覆盖。
+
+原始标题:编排层的五处加固(2026-08-10 机器审逮到,逐条锁死)。
+
+共同点:全部在**测试全绿的情况下**存在——注入桩的测试走不到真实装配路径,
+mock 的 EventBus 不涉及跨线程,请求模型的上界靠"没人会填大数"活着。
+"""
+from __future__ import annotations
+
+import asyncio
+import threading
+from unittest.mock import Mock
+
+import httpx
+import pytest
+
+from windup_app.server.media.model import MediaUploadInput
+from windup_app.server.media.service import ObjectStorageMediaService
+from windup_app.server.orchestrator._fetch import (
+ MAX_FETCH_BYTES,
+ FetchNotAllowed,
+ fetch_own_media,
+)
+from windup_app.server.orchestrator.model import TaskStatus
+from windup_app.web.api.generation import (
+ _TERMINAL_EVENTS,
+ CharacterActionGenerateRequest,
+ CharacterImageGenerateRequest,
+ _EventBus,
+)
+from windup_common.enums.media import MediaCategory
+from windup_framework.config.storage import StorageSettings
+
+
+def test_generation_dispatcher_serializes_provider_work():
+ from windup_app.server.orchestrator.dispatcher import GenerationDispatcher
+
+ dispatcher = GenerationDispatcher()
+ first_started = threading.Event()
+ release_first = threading.Event()
+ second_started = threading.Event()
+
+ def first_task():
+ first_started.set()
+ release_first.wait(timeout=5)
+
+ def second_task():
+ second_started.set()
+
+ try:
+ dispatcher.submit(first_task)
+ assert first_started.wait(timeout=3)
+
+ dispatcher.submit(second_task)
+ assert not second_started.wait(timeout=0.1)
+
+ release_first.set()
+ assert second_started.wait(timeout=3)
+ finally:
+ release_first.set()
+ dispatcher.shutdown()
+
+
+def test_generation_dispatch_starts_only_after_commit(db_session):
+ from windup_app.web.api.generation import _dispatch_after_commit
+
+ dispatcher = Mock()
+ target = Mock()
+
+ _dispatch_after_commit(db_session, dispatcher, target, 7, "payload")
+ dispatcher.submit.assert_not_called()
+
+ db_session.commit()
+
+ dispatcher.submit.assert_called_once_with(target, 7, "payload")
+
+
+@pytest.mark.parametrize(
+ ("configured", "expected"),
+ [
+ ("cdn.example.com", "https://cdn.example.com"),
+ ("https://cdn.example.com/", "https://cdn.example.com"),
+ ("", ""),
+ ],
+)
+def test_storage_download_base_accepts_documented_bare_domain(configured, expected):
+ assert StorageSettings(bucket_domain=configured).download_base == expected
+
+
+@pytest.mark.parametrize(
+ "configured",
+ [
+ "example-bucket.s3.cn-east-1.qiniucs.com",
+ "https://example-bucket.s3.cn-east-1.qiniucs.com",
+ "https://s3-cn-east-1.qiniucs.com/example-bucket",
+ ],
+)
+def test_storage_download_base_rejects_qiniu_s3_api_endpoint(configured):
+ with pytest.raises(ValueError, match="S3 API"):
+ StorageSettings(bucket_domain=configured).download_base
+
+
+def test_storage_download_base_rejects_plain_http():
+ with pytest.raises(ValueError, match="HTTPS"):
+ StorageSettings(bucket_domain="http://cdn.example.com").download_base
+
+
+def test_media_upload_rejects_s3_endpoint_before_uploading(monkeypatch):
+ import qiniu
+ import windup_app.server.media.service as media_service_module
+
+ monkeypatch.setattr(
+ media_service_module.storage_settings,
+ "bucket_domain",
+ "https://example-bucket.s3.cn-east-1.qiniucs.com",
+ )
+ put_data = Mock()
+ monkeypatch.setattr(qiniu, "put_data", put_data)
+
+ metadata = MediaUploadInput(
+ filename="character.png",
+ content_type="image/png",
+ size=3,
+ category=MediaCategory.REFERENCE_IMAGE,
+ )
+ with pytest.raises(ValueError, match="S3 API"):
+ ObjectStorageMediaService().upload(b"png", metadata)
+
+ put_data.assert_not_called()
+
+
+def test_media_upload_rejects_plain_http_before_uploading(monkeypatch):
+ import qiniu
+ import windup_app.server.media.service as media_service_module
+
+ monkeypatch.setattr(
+ media_service_module.storage_settings,
+ "bucket_domain",
+ "http://cdn.example.com",
+ )
+ put_data = Mock()
+ monkeypatch.setattr(qiniu, "put_data", put_data)
+
+ metadata = MediaUploadInput(
+ filename="character.png",
+ content_type="image/png",
+ size=3,
+ category=MediaCategory.REFERENCE_IMAGE,
+ )
+ with pytest.raises(ValueError, match="HTTPS"):
+ ObjectStorageMediaService().upload(b"png", metadata)
+
+ put_data.assert_not_called()
+
+
+def test_media_upload_uses_validated_download_base(monkeypatch):
+ import qiniu
+ import windup_app.server.media.service as media_service_module
+
+ monkeypatch.setattr(
+ media_service_module.storage_settings,
+ "bucket_domain",
+ "cdn.example.com",
+ )
+ monkeypatch.setattr(
+ media_service_module.storage_settings,
+ "bucket_name",
+ "example-bucket",
+ )
+ auth = Mock()
+ auth.upload_token.return_value = "upload-token"
+ monkeypatch.setattr(qiniu, "Auth", Mock(return_value=auth))
+ response = Mock(status_code=200)
+ put_data = Mock(return_value=({"key": "uploaded"}, response))
+ monkeypatch.setattr(qiniu, "put_data", put_data)
+
+ metadata = MediaUploadInput(
+ filename="character.png",
+ content_type="image/png",
+ size=3,
+ category=MediaCategory.REFERENCE_IMAGE,
+ )
+ result = ObjectStorageMediaService().upload(b"png", metadata)
+
+ assert result.url == f"https://cdn.example.com/{result.object_key}"
+ auth.upload_token.assert_called_once_with("example-bucket", result.object_key)
+ put_data.assert_called_once_with(
+ "upload-token",
+ result.object_key,
+ b"png",
+ mime_type="image/png",
+ )
+
+
+# ── ① 真实装配路径不能引用已删除的路线 ────────────────────────────────────
+
+
+def test_real_generator_assembly_covers_every_declared_route():
+ """曾多装一个 PROC_IDLE:该枚举与 ProcIdleStrategy 都已随「程序化待机放弃」
+ 删除,而装配那行留着,于是每个动作任务在 import 期 AttributeError。
+
+ 注入 generator 的测试走不到这条路径 —— 所以这条必须直接调真实装配。
+ """
+ # RENDER_3D 这条路线的 provider 在 1024XEngineer/Windup#270。缺件时装配必然少一条,
+ # 断言必红 —— 那是缺件不是漏装,所以显式跳过而不是放宽断言。
+ pytest.importorskip(
+ "windup_framework.providers.render3d",
+ reason="缺三渲二 provider 层(1024XEngineer/Windup#270),RENDER_3D 无法装配",
+ )
+ from windup_common.models import GenRoute
+ from windup_app.server.orchestrator.executor import ActionTaskExecutor
+
+ gen = ActionTaskExecutor()._get_generator()
+ wired = set(gen._by_route)
+ assert wired == set(GenRoute), (
+ f"GenRoute 声明了 {sorted(r.value for r in GenRoute)},"
+ f"装配了 {sorted(r.value for r in wired)} —— 漏装的路线一被请求就崩"
+ )
+
+
+# ── ② 服务端取图必须白名单 ────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("evil", [
+ "http://127.0.0.1:8000/auth/me", # 打回自己,绕过鉴权中间件
+ "http://169.254.169.254/latest/meta-data/", # 云实例元数据服务
+ "http://10.0.0.5/internal", # 私网探测
+ "file:///etc/passwd",
+ "http://[::1]:8000/",
+])
+def test_server_side_fetch_rejects_non_own_urls(evil: str, monkeypatch):
+ """URL 来自已认证请求的请求体,直接 httpx.get 等于把服务器当跳板。"""
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com")
+ with pytest.raises(FetchNotAllowed):
+ fetch_own_media(evil)
+
+
+def test_server_side_fetch_refuses_when_storage_domain_unset(monkeypatch):
+ """下载域名没配时不能"放行一切"——那等于白名单形同虚设。"""
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "")
+ with pytest.raises(FetchNotAllowed, match="未配置"):
+ fetch_own_media("https://cdn.example.com/a.png")
+
+
+def test_prefix_match_is_not_fooled_by_a_lookalike_host(monkeypatch):
+ """`cdn.example.com.evil.com` 不能因为前缀相似而通过。"""
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com")
+ with pytest.raises(FetchNotAllowed):
+ fetch_own_media("https://cdn.example.com.evil.com/a.png")
+
+
+def test_fetch_size_cap_is_bounded():
+ """上限存在且是个有限的正数——无上限时一个指向大文件的 URL 就能吃光 worker 内存。"""
+ assert 0 < MAX_FETCH_BYTES <= 64 * 1024 * 1024
+
+
+# 下面四条覆盖**真正下载那一段**。此前只测了"坏 URL 被拒",而放行之后的三条防线
+# (不跟重定向 / 声明超限 / 声明撒谎时边读边截)一行都没跑过 —— 而它们恰恰是
+# 白名单被绕过时唯一的兜底。用 MockTransport,不联网。
+
+
+def _install_mock_client(monkeypatch, handler):
+ """把 _fetch 模块里的 httpx.Client 换成走 MockTransport 的,并记录构造参数。"""
+ import windup_app.server.orchestrator._fetch as F
+
+ seen: dict = {}
+ real_client = httpx.Client # 必须先抓真的:补丁装上后 httpx.Client 就是 factory 自己
+
+ def factory(*args, **kw):
+ seen.update(kw)
+ return real_client(
+ transport=httpx.MockTransport(handler),
+ follow_redirects=bool(kw.get("follow_redirects", False)),
+ )
+
+ monkeypatch.setattr(F.httpx, "Client", factory)
+ return seen
+
+
+def test_fetch_returns_body_for_own_url(monkeypatch):
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com")
+ _install_mock_client(monkeypatch, lambda req: httpx.Response(200, content=b"PNGDATA"))
+ assert fetch_own_media("https://cdn.example.com/a.png") == b"PNGDATA"
+
+
+def test_fetch_does_not_follow_redirects(monkeypatch):
+ """自家域名返回 302 指向别处时**不许跟过去** —— 跟了白名单就等于没有。
+
+ 这条是白名单最容易被绕开的方式:URL 本身完全合规,坏事发生在重定向之后。
+ """
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com")
+ asked: list[str] = []
+
+ def handler(req):
+ asked.append(str(req.url))
+ if "cdn.example.com" in str(req.url):
+ return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"})
+ return httpx.Response(200, content=b"SECRET")
+
+ seen = _install_mock_client(monkeypatch, handler)
+ # 断言具体异常类型,不用裸 Exception —— 那样连 RecursionError 都算"通过",
+ # 测试会因为错误的原因变绿(本条初版就栽在这)。
+ with pytest.raises(httpx.HTTPStatusError): # 3xx 不是 success,raise_for_status 会抛
+ fetch_own_media("https://cdn.example.com/a.png")
+ assert seen.get("follow_redirects") is False, "构造 Client 时必须显式关掉重定向"
+ assert not any("169.254.169.254" in u for u in asked), f"跟着重定向打到了元数据服务: {asked}"
+
+
+def test_fetch_rejects_declared_oversize_before_reading_body(monkeypatch):
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com")
+ too_big = str(MAX_FETCH_BYTES + 1)
+ _install_mock_client(
+ monkeypatch,
+ lambda req: httpx.Response(200, headers={"content-length": too_big}, content=b"x"),
+ )
+ with pytest.raises(FetchNotAllowed, match="超过上限"):
+ fetch_own_media("https://cdn.example.com/big.png")
+
+
+def test_fetch_rejects_when_content_length_lies(monkeypatch):
+ """Content-Length 可以缺失或撒谎,所以必须边读边计数。
+
+ 只信 Content-Length 的话,声明 1 字节、实际吐 100MB 就能吃光 worker 内存。
+ """
+ import windup_app.server.orchestrator._fetch as F
+
+ monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com")
+ monkeypatch.setattr(F, "MAX_FETCH_BYTES", 1024)
+ _install_mock_client(
+ monkeypatch,
+ lambda req: httpx.Response(200, headers={"content-length": "1"}, content=b"x" * 4096),
+ )
+ with pytest.raises(FetchNotAllowed, match="超过上限"):
+ fetch_own_media("https://cdn.example.com/liar.png")
+
+
+# ── ③ 终态事件名必须与 SSE 契约一致 ──────────────────────────────────────
+
+
+@pytest.mark.parametrize(("status", "expected"), [
+ (TaskStatus.COMPLETED, "completed"),
+ (TaskStatus.FAILED, "failed"),
+ (TaskStatus.RUNNING, "task_update"),
+])
+def test_terminal_states_publish_terminal_event_names(status, expected, monkeypatch):
+ """一律发 task_update 的话,stream 的终态判断永不成立:客户端收到 completed
+ 后连接仍开着,而端点带 retry: 3000,浏览器每 3 秒重连、重收同一条 completed。
+
+ 走**真实的 _publish_task_update 调用路径**,不读 _STATUS_EVENT 字典 —— 只断言
+ 字典内容的话,把 `event = _STATUS_EVENT.get(...)` 改成 `event = "task_update"`
+ 测试照样绿(2026-08-10 变异测试逮到这条是摆设)。
+ """
+ import windup_app.server.orchestrator.task_repo as R
+ from windup_app.server.orchestrator.model import GenerationTask, GenerationType
+
+ sent: list[str] = []
+
+ class _Bus:
+ def publish(self, project_id, task_id, event, data):
+ sent.append(event)
+
+ monkeypatch.setattr(R, "_event_bus", _Bus())
+ # 必须带 project_id:EventBus 按 (project_id, task_id) 索引,_publish_task_update
+ # 对 project_id 为空的任务会记 warning 并早退(发到没人听的键上等于静默失败)。
+ R._publish_task_update(1, GenerationTask(
+ id=1, user_id=1, project_id=42,
+ task_type=GenerationType.CHARACTER_ACTION, status=status,
+ ))
+ assert sent == [expected]
+
+
+def test_every_terminal_event_name_is_recognised_by_the_stream():
+ """两边是一套契约的两半,任何一边改了名字必须让另一边失败。"""
+ from windup_app.server.orchestrator.task_repo import _STATUS_EVENT
+
+ assert set(_STATUS_EVENT.values()) == _TERMINAL_EVENTS
+
+
+# ── ④ EventBus 跨线程投递 ────────────────────────────────────────────────
+
+
+def test_publish_from_another_thread_delivers():
+ """executor 在独立工作线程里跑,队列属于处理 SSE 请求的那个 loop。
+
+ 诚实说明本用例的强度:它只证明跨线程发布**能到达**订阅者,**证不出**
+ call_soon_threadsafe 是必需的 —— 实测在这个单队列场景里,裸 put_nowait
+ 跨线程也能被 get() 取到(CPython 的 Queue.get 在有元素时走快路径、不等唤醒)。
+
+ call_soon_threadsafe 仍然要留:asyncio.Queue 的文档明说它不是线程安全的,
+ 上面那个"能取到"是实现细节而非保证——多个 waiter、队列非空判定与唤醒之间
+ 的竞态都可能让它失效。真正的保证由下一条用例(订阅记录 loop)间接锁住。
+
+ 先等发布线程真的调完再取(用一个 future 同步),否则测的是"抢跑运气"而不是投递:
+ publish 走的是跨 loop 分支,marshal 回来要等 loop 一次迭代。
+ """
+ bus = _EventBus()
+
+ async def scenario():
+ queue = await bus.subscribe(42, 7)
+ loop = asyncio.get_running_loop()
+ published = loop.create_future()
+
+ def publish_from_thread():
+ bus.publish(42, 7, "completed", {"id": 7})
+ loop.call_soon_threadsafe(published.set_result, None)
+
+ threading.Thread(target=publish_from_thread, daemon=True).start()
+ await asyncio.wait_for(published, timeout=3.0)
+ return await asyncio.wait_for(queue.get(), timeout=3.0)
+
+ event, data = asyncio.run(scenario())
+ assert event == "completed" and data["id"] == 7
+
+
+def test_subscription_records_its_owning_loop():
+ """订阅必须记下所属 loop —— 这是跨线程安全投递的前提。
+
+ 只存 queue 的话,publish 无从知道该把入队动作 marshal 回哪个 loop;
+ 不同订阅者可能来自不同 loop(多 worker / 测试里的临时 loop),存一个全局
+ loop 也不行。本用例锁住"每个订阅都带着自己的 loop"这个结构。
+ """
+ bus = _EventBus()
+
+ async def scenario():
+ q = await bus.subscribe(42, 11)
+ subs = bus._queues[(42, 11)]
+ assert len(subs) == 1
+ queue, loop = subs[0]
+ assert queue is q
+ assert loop is asyncio.get_running_loop()
+
+ asyncio.run(scenario())
+
+
+def test_publish_to_a_closed_loop_is_dropped_not_raised():
+ """客户端断连后请求 loop 已关闭。此时发布应静默丢弃——任务状态本身已落库,
+ 重连后靠 GET /tasks/{id} 取;让它抛异常会把后台任务整个带崩。
+ """
+ bus = _EventBus()
+
+ async def sub():
+ return await bus.subscribe(42, 9)
+
+ loop = asyncio.new_event_loop()
+ queue = loop.run_until_complete(sub())
+ loop.close()
+ assert queue is not None
+ bus.publish(42, 9, "completed", {"id": 9}) # 不应抛
+
+
+def test_unsubscribe_removes_only_that_queue():
+ """订阅记的是 (queue, loop) 元组,退订不能顺手把同一任务的其他订阅者删掉。"""
+ bus = _EventBus()
+
+ async def scenario():
+ q1 = await bus.subscribe(42, 3)
+ q2 = await bus.subscribe(42, 3)
+ await bus.unsubscribe(42, 3, q1)
+ bus.publish(42, 3, "task_update", {"n": 1})
+ got = await asyncio.wait_for(q2.get(), timeout=2.0)
+ assert got[1]["n"] == 1
+ assert q1.empty()
+
+ asyncio.run(scenario())
+
+
+# ── ⑤ 付费循环必须有上界 ─────────────────────────────────────────────────
+
+
+def test_num_images_is_bounded_at_the_contract_layer():
+ """num_images 是 provider 调用次数的循环上界:一个已认证请求填个大数就能
+ 绕过按请求计的限流,把成本拉到无上限。
+ """
+ with pytest.raises(ValueError):
+ CharacterImageGenerateRequest(project_id=42, prompt="x", num_images=10_000)
+ with pytest.raises(ValueError):
+ CharacterImageGenerateRequest(project_id=42, prompt="x", num_images=0)
+ assert CharacterImageGenerateRequest(project_id=42, prompt="x", num_images=2).num_images == 2
+
+
+def test_image_dimensions_are_bounded():
+ with pytest.raises(ValueError):
+ CharacterImageGenerateRequest(project_id=42, prompt="x", width=100_000)
+ with pytest.raises(ValueError):
+ CharacterImageGenerateRequest(project_id=42, prompt="x", height=1)
+
+
+def test_num_frames_is_bounded():
+ """帧数决定抽帧与逐帧抠图的工作量。"""
+ with pytest.raises(ValueError):
+ CharacterActionGenerateRequest(project_id=42, character_id=1, action_type="walk", num_frames=100_000)
+ with pytest.raises(ValueError):
+ CharacterActionGenerateRequest(project_id=42, character_id=1, action_type="walk", num_frames=0)
+ ok = CharacterActionGenerateRequest(project_id=42, character_id=1, action_type="walk", num_frames=16)
+ assert ok.num_frames == 16
+
+
+def test_custom_action_requires_a_non_empty_prompt():
+ for prompt in (None, "", " "):
+ with pytest.raises(ValueError):
+ CharacterActionGenerateRequest(
+ project_id=42,
+ character_id=1,
+ action_type="custom",
+ custom_prompt=prompt,
+ )
+ request = CharacterActionGenerateRequest(
+ project_id=42,
+ character_id=1,
+ action_type="custom",
+ custom_prompt=" wave hello ",
+ )
+ assert request.custom_prompt == "wave hello"
+
+
+# ── ⑥ 请求里的尺寸必须真的生效(2026-08-10 对抗复查)────────────────────────
+
+
+def _png(w: int, h: int) -> bytes:
+ """带细节的图。纯色图在 NEAREST 与 LANCZOS 下产出完全相同,拿它验重采样是无效仪器
+ (2026-08-10 第一版就是这么写的,测试立刻变红)。这里用 8px 棋盘格。"""
+ import io
+
+ import numpy as np
+ from PIL import Image
+
+ y, x = np.mgrid[0:h, 0:w]
+ checker = (((x // 8) + (y // 8)) % 2 * 255).astype("uint8")
+ arr = np.dstack([checker, 255 - checker, checker, np.full((h, w), 255, "uint8")])
+ buf = io.BytesIO()
+ Image.fromarray(arr, "RGBA").save(buf, "PNG")
+ return buf.getvalue()
+
+
+@pytest.mark.parametrize(("want_w", "want_h"), [(512, 512), (256, 384), (1024, 1024)])
+def test_requested_image_size_is_actually_applied(want_w, want_h):
+ """入口收下 width/height 并校验过,但 ImageProvider.gen_image 没有尺寸参数。
+
+ 此前模型出多大就返多大:调用方要 512×512、拿到 1024×1024,而请求被接受了 ——
+ 又一个"接了不履约"的字段。本用例锁住"要多大就得多大"。
+ """
+ import io
+
+ from PIL import Image
+
+ from windup_app.server.orchestrator.executor import ImageTaskExecutor
+ from windup_app.server.orchestrator.model import CharacterImageInput
+
+ class _Gen:
+ def gen_image(self, prompt, refs):
+ return _png(1024, 1024) # 模型固定出 1024²
+
+ got: list[bytes] = []
+ ex = ImageTaskExecutor(image=_Gen(), upload=lambda b: (got.append(b), "u")[1])
+ ex._produce_image(
+ CharacterImageInput(prompt="knight", width=want_w, height=want_h, num_images=1),
+ _constraints(),
+ )
+ assert Image.open(io.BytesIO(got[0])).size == (want_w, want_h)
+
+
+def test_sprite_frames_and_master_use_different_resampling():
+ """序列帧是像素画,必须 NEAREST;全彩母版用 NEAREST 缩图会明显锯齿。
+
+ 只断言两条路径产出不同 —— 同一张图两种重采样若字节相同,说明 smooth 参数没接上。
+ """
+ from windup_app.server.orchestrator.executor import _fit_to
+
+ src = _png(1024, 1024)
+ assert _fit_to(src, 256, 256, smooth=False) != _fit_to(src, 256, 256, smooth=True)
+
+
+def _constraints():
+ """最小项目约束(本文件只关心尺寸这条链路)。"""
+ from windup_app.server.orchestrator.executor import _load_constraints # noqa: F401
+ from windup_app.server.orchestrator.executor import ProjectConstraints
+
+ return ProjectConstraints()
diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py
new file mode 100644
index 00000000..27bd460c
--- /dev/null
+++ b/backend/tests/test_pack_align.py
@@ -0,0 +1,431 @@
+"""align_bottom_center 的交付画布几何(2026-08-11 挣得)。
+
+为什么要这组用例:交付帧一直写死出 256×256 方形,而项目的 sprite 尺寸是
+``sprite_width×sprite_height``(32~2048,可非方)。上层拿到 256 的帧再 ``_fit_to``
+到项目尺寸,用的是 ``Image.thumbnail`` —— **它只缩不放**:项目要 512 时帧根本不会被
+放大,而是原尺寸居中贴进 512 画布,刚对齐好的脚线 0.92 被挪到 0.709(实测),角色不站
+在地上了。所以引擎必须能一次出到目标尺寸,而不是让上层再缩一次。
+"""
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from windup_ai_engine.postprocess.pack import align_bottom_center
+
+FILL_H = 0.62 # 与 pack.align_bottom_center 的默认值一致
+FOOT_LINE = 0.92
+
+
+def _frames(n=4, w=640, h=480, bh=300, bw=60):
+ """造一组"角色在画布里漂移"的帧(align 要消掉的正是这个漂移)。"""
+ out = []
+ for i in range(n):
+ a = np.zeros((h, w, 4), dtype=np.uint8)
+ x0, y0 = 200 + i * 7, 60 + i * 5
+ a[y0:y0 + bh, x0:x0 + bw] = (200, 80, 60, 255)
+ out.append(Image.fromarray(a, "RGBA"))
+ return out
+
+
+def _subject(img: Image.Image):
+ """返回 (高, 脚线比例, 水平中心比例)。"""
+ a = np.asarray(img)[:, :, 3]
+ ys, xs = np.nonzero(a > 128)
+ w, h = img.size
+ return int(ys.max() - ys.min() + 1), (int(ys.max()) + 1) / h, (int(xs.min()) + int(xs.max())) / 2 / w
+
+
+def test_default_canvas_is_256_square_with_foot_line_geometry():
+ """默认仍是 256 方形,脚线 0.92、主体占高 0.62、水平居中。"""
+ out = align_bottom_center(_frames(), ref_height=300.0)
+ assert out[0].size == (256, 256)
+ height, foot, center = _subject(out[0])
+ assert abs(height - 256 * FILL_H) <= 2
+ assert abs(foot - FOOT_LINE) <= 0.01
+ assert abs(center - 0.5) <= 0.01
+
+
+def test_omitting_cell_h_is_pixel_identical_to_square_cell():
+ """不传 cell_h == 传 cell_h=cell —— 默认行为一个像素都不许变。"""
+ src = _frames()
+ a = align_bottom_center(src, ref_height=300.0)
+ b = align_bottom_center(src, ref_height=300.0, cell_h=256)
+ for x, y in zip(a, b, strict=True):
+ assert np.array_equal(np.asarray(x), np.asarray(y))
+
+
+def test_doubling_cell_doubles_subject_height():
+ """指定 512 时交付帧主体高度翻倍 —— 这正是"交付帧太小"的修法。"""
+ src = _frames()
+ small = align_bottom_center(src, ref_height=300.0)
+ big = align_bottom_center(src, ref_height=300.0, cell=512)
+ assert big[0].size == (512, 512)
+ h_small = _subject(small[0])[0]
+ h_big = _subject(big[0])[0]
+ assert abs(h_big / h_small - 2.0) < 0.05, f"期望约翻倍,实际 {h_small} → {h_big}"
+
+
+def test_non_square_canvas_applies_each_axis_to_the_right_dimension():
+ """非方形画布:高度几何(脚线 / 占高)按高走,水平居中按宽走 —— 不能串轴。"""
+ out = align_bottom_center(_frames(), ref_height=300.0, cell=384, cell_h=512)
+ assert out[0].size == (384, 512)
+ height, foot, center = _subject(out[0])
+ assert abs(height - 512 * FILL_H) <= 2, "主体占高必须按画布高算"
+ assert abs(foot - FOOT_LINE) <= 0.01, "脚线必须按画布高算"
+ assert abs(center - 0.5) <= 0.01, "水平居中必须按画布宽算"
+
+
+def test_subject_fill_ratio_is_scale_invariant():
+ """几何是"比例"不是"像素":换画布尺寸,主体占画布高的比例不变。
+
+ 这条是"母版入口预检与出帧共用同一套几何"的直接证据 —— 预检阈值
+ (master_check.REJECT_ASPECT = 2*FILL_W/FILL_H)里没有 cell,本就与画布像素尺寸无关。
+ """
+ src = _frames()
+ ratios = []
+ for cell in (128, 256, 512, 1024):
+ out = align_bottom_center(src, ref_height=300.0, cell=cell)
+ ratios.append(_subject(out[0])[0] / cell)
+ assert max(ratios) - min(ratios) < 0.01, f"占高比例应恒定,实测 {ratios}"
+
+
+def test_width_fallback_uses_canvas_width_not_height():
+ """宽度兜底(横向长条主体)要按画布**宽**收缩,否则宽画布上会白白缩小主体。"""
+ wide = [f.transpose(Image.ROTATE_90) for f in _frames(bh=300, bw=60)]
+ narrow = align_bottom_center(wide, cell=256, cell_h=256)
+ widened = align_bottom_center(wide, cell=512, cell_h=256)
+ # 画布变宽后,宽度兜底放松,主体应当更大(若按高算则两者相同)
+ assert _subject(widened[0])[0] > _subject(narrow[0])[0]
+
+
+def test_non_positive_canvas_raises_instead_of_emitting_empty_image():
+ """0 边长不静默出图:PIL 允许建 0×0,错产物要到落库/前端才暴露。"""
+ import pytest
+
+ for kw in (dict(cell=0), dict(cell_h=0), dict(cell=-1)):
+ with pytest.raises(ValueError, match="画布尺寸"):
+ align_bottom_center(_frames(), **kw)
+
+
+def test_all_transparent_frames_still_honour_requested_canvas():
+ """全透明输入的兜底画布也要用请求的尺寸,不能退回 256 方形。"""
+ blank = [Image.new("RGBA", (64, 64), (0, 0, 0, 0)) for _ in range(3)]
+ out = align_bottom_center(blank, cell=320, cell_h=200)
+ assert [f.size for f in out] == [(320, 200)] * 3
+
+
+# ── 跨动作尺寸一致性:定标基准不许被延展物撑大 ────────────────────────────
+
+
+def _body(cell: int, bw: int, bh: int, exts=(), n: int = 8, size: int = 240):
+ """本体尺寸恒定的合成序列;延展物按动作不同。本体用红色标记,便于在交付帧里量它。"""
+ import numpy as np
+
+ out = []
+ for i in range(n):
+ a = np.zeros((size, size, 4), np.uint8)
+ y1 = size - 30
+ y0, x0 = y1 - bh, (size - bw) // 2
+ x1 = x0 + bw
+ a[y0:y1, x0:x1] = (200, 80, 80, 255) # 本体
+ for d, amp, osc in exts:
+ k = int(amp * (abs(np.sin(i / n * 2 * np.pi)) if osc else 1.0))
+ if k <= 0:
+ continue
+ if d == "up":
+ a[max(0, y0 - k):y0, x0 + bw // 3:x0 + bw // 3 + 8] = (180, 140, 90, 255)
+ elif d == "side":
+ a[y0 + bh // 3:y0 + bh // 3 + 8, max(0, x0 - k):x0] = (180, 140, 90, 255)
+ elif d == "down":
+ a[y1:min(size, y1 + k), x0:x0 + 10] = (180, 140, 90, 255)
+ elif d == "wing":
+ a[y0:y0 + 10, max(0, x0 - k):x0] = (180, 140, 90, 255)
+ a[y0:y0 + 10, x1:min(size, x1 + k)] = (180, 140, 90, 255)
+ out.append(Image.fromarray(a))
+ return out
+
+
+def _delivered_body_height(frames, cell=256):
+ import numpy as np
+
+ hs = []
+ for f in align_bottom_center(frames, cell=cell, cell_h=cell):
+ a = np.asarray(f)
+ m = (a[:, :, 0] > 150) & (a[:, :, 1] < 120) & (a[:, :, 3] > 128)
+ ys, _ = np.where(m)
+ hs.append(float(ys.max() - ys.min()) if len(ys) else 0.0)
+ return float(np.median(hs))
+
+
+# 四个体形族,每族内本体尺寸相同、只有延展物随动作变。
+# **必须覆盖人形以外的体形** —— 现状那条宽度兜底的注释自己写着它是"人形先验",
+# 当时为四足打了补丁,鸟和龙又漏了。
+_FAMILIES = {
+ "humanoid": (40, 110, [
+ ("idle", ()),
+ ("walk_cape", (("side", 40, True),)),
+ ("raise_weapon", (("up", 50, False),)),
+ ("run_cape_weapon", (("side", 50, True), ("up", 40, False))),
+ ]),
+ "quadruped": (110, 55, [
+ ("idle_tail", (("up", 8, True),)),
+ ("walk_tail", (("up", 30, True),)),
+ ("run_tail_ears", (("up", 55, True),)),
+ ("howl_tail_down", (("down", 40, False),)),
+ ]),
+ "bird": (50, 70, [
+ ("perch", (("wing", 6, False),)),
+ ("flap_small", (("wing", 35, True),)),
+ ("wings_wide", (("wing", 70, True),)),
+ ]),
+ "dragon": (130, 50, [
+ ("idle", (("down", 10, True),)),
+ ("fly_long_tail", (("down", 60, True),)),
+ ("wings_out", (("wing", 50, True),)),
+ ]),
+}
+
+
+@pytest.mark.parametrize("family", sorted(_FAMILIES))
+def test_body_size_is_stable_across_actions(family):
+ """同一角色不同动作,交付帧里本体高度必须一致 —— 延展物不得影响定标。"""
+ bw, bh, actions = _FAMILIES[family]
+ got = {name: _delivered_body_height(_body(256, bw, bh, ext)) for name, ext in actions}
+ base = got[actions[0][0]]
+ for name, h in got.items():
+ drift = abs(h - base) / base
+ assert drift <= 0.02, (
+ f"{family}/{name} 本体高 {h:.0f}px vs 基准 {base:.0f}px,偏差 {drift:.1%};"
+ f" 全部: { {k: round(v) for k, v in got.items()} }"
+ )
+
+
+def test_core_span_ignores_extremities():
+ """本体跨度只认厚薄,不认延展物的方向或语义。"""
+ from windup_ai_engine.postprocess.pack import core_span
+
+ plain = _body(256, 60, 90)[0]
+ base_h, base_w = core_span(plain)
+ for tag, ext in (("上举", ("up", 60, False)), ("侧展", ("side", 60, False)),
+ ("下垂", ("down", 60, False)), ("两翼", ("wing", 60, False))):
+ h, w = core_span(_body(256, 60, 90, (ext,))[0])
+ assert abs(h - base_h) <= 2 and abs(w - base_w) <= 2, (
+ f"{tag}延展物影响了本体跨度: ({h},{w}) vs ({base_h},{base_w})")
+
+
+def test_core_span_returns_none_for_empty_frame():
+ from windup_ai_engine.postprocess.pack import core_span
+
+ assert core_span(Image.new("RGBA", (32, 32), (0, 0, 0, 0))) is None
+
+
+def test_size_is_kept_even_when_extremities_overflow():
+ """延展物装不进画布时保尺寸,不为它压缩角色。
+
+ 两个目标冲突,后果不对称:压缩会让同一角色在两个动作间差到 4 成,溢出只丢翅尖那几列。
+ """
+
+ base = _delivered_body_height(_body(256, 50, 70))
+ for wing in (20, 50, 80):
+ src = _body(256, 50, 70, (("wing", wing, False),))
+ got = _delivered_body_height(src)
+ assert abs(got - base) / base <= 0.02, (
+ f"翅展 {wing}px 时本体高 {got:.0f}px vs 基准 {base:.0f}px —— "
+ "为装进画布压缩了角色")
+
+
+def test_clipping_is_logged_not_silent(caplog):
+ """选择让延展物溢出时必须上报 —— 丢像素不能靠人看图发现。"""
+ import logging
+
+ src = _body(256, 50, 70, (("wing", 80, False),))
+ with caplog.at_level(logging.INFO, logger="windup_ai_engine.postprocess.pack"):
+ align_bottom_center(src, cell=256, cell_h=256)
+ assert any("溢出" in r.message for r in caplog.records), \
+ f"裁切没有上报,日志:{[r.message for r in caplog.records]}"
+
+
+# ── 一段动作内的单调漂移(#307)──────────────────────────────────────────────
+#
+# 线上真实产出实测:walk 的本体高 137→165(+20%)、custom 70→158(+127%),几乎无回落。
+# 整段共用一个缩放系数只决定平均尺寸,趋势原样保留,于是角色在一个动作内单调变大。
+
+
+# 任务 94(walk,32 帧)的逐帧本体高,直接取自线上产物。
+_REAL_WALK_SPANS = [
+ 132, 133, 136, 139, 141, 142, 139, 138, 141, 146, 146, 148, 146, 145, 149, 155,
+ 155, 153, 151, 154, 157, 161, 160, 161, 159, 160, 162, 170, 169, 167, 168, 168,
+]
+
+
+def test_monotonic_drift_is_removed_on_real_data():
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ comp, ratio = scale_drift(_REAL_WALK_SPANS)
+ assert ratio > 0.15, "这段真实数据本身就有 20% 漂移,判不出来说明门槛错了"
+ fixed = np.asarray(_REAL_WALK_SPANS, float) / np.asarray(comp)
+ head, tail = fixed[:8].mean(), fixed[-8:].mean()
+ assert abs(tail / head - 1) < 0.03, f"补偿后首尾仍差 {(tail/head-1)*100:.1f}%"
+
+
+def test_natural_bob_is_preserved_not_flattened():
+ """只除趋势、不逐帧归一 —— 走路自然的身高起伏必须留着。
+
+ 逐帧归一会把蹲下的帧放大、伸展的帧缩小,那正是本模块最初拒绝它的原因。
+ """
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ comp, _ = scale_drift(_REAL_WALK_SPANS)
+ fixed = np.asarray(_REAL_WALK_SPANS, float) / np.asarray(comp)
+ spread = fixed.std() / fixed.mean()
+ assert spread > 0.005, "起伏被压平了,退化成逐帧归一"
+ assert spread < 0.10, f"残差 {spread*100:.1f}% 过大,趋势没除干净"
+
+
+def test_steady_sequence_is_left_alone():
+ """没有漂移就不该动。真实身高起伏约 4%,把那当漂移消掉是过度矫正。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ steady = [100, 104, 98, 102, 101, 99, 103, 100] * 4
+ comp, ratio = scale_drift(steady)
+ assert abs(ratio) < 0.08
+ assert all(c == 1.0 for c in comp)
+
+
+def test_average_size_is_unchanged_so_cross_action_scale_still_holds():
+ """补偿系数以 1.0 为中心:整段平均尺寸不变,#280 的跨动作口径不受影响。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ comp, _ = scale_drift(_REAL_WALK_SPANS)
+ assert abs(float(np.mean(comp)) - 1.0) < 0.01
+
+
+def test_too_few_frames_are_left_alone():
+ """三帧拟合不出可信趋势,拟合了反而制造漂移。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ comp, ratio = scale_drift([100, 130, 160])
+ assert comp == [1.0, 1.0, 1.0] and ratio == 0.0
+
+
+def _drifting_bodies(n=16, lo=60, hi=140):
+ """本体高从 lo 单调涨到 hi 的合成序列,形状与线上观测到的推镜一致。"""
+ def body(h: int) -> Image.Image:
+ a = np.zeros((256, 256, 4), np.uint8)
+ w = max(2, h // 3)
+ a[200 - h:200, 128 - w // 2:128 + w // 2, 3] = 255
+ return Image.fromarray(a)
+
+ return [body(int(round(v))) for v in np.linspace(lo, hi, n)]
+
+
+def test_drift_is_still_compensated_when_a_frame_is_empty():
+ """中间夹一帧全透明,其余帧的漂移照样要补掉。
+
+ 空帧只是**缺一个观测**。整段跳过补偿会让其余帧静默留着漂移 —— 本 PR 要修的问题
+ 原样回来,且无声无息。
+ """
+ from windup_ai_engine.postprocess.pack import align_bottom_center, core_span
+
+ src = _drifting_bodies()
+ src[8] = Image.new("RGBA", (256, 256), (0, 0, 0, 0))
+
+ out = align_bottom_center(src, cell=256)
+ assert core_span(out[8]) is None, "空帧必须原样透明输出"
+
+ got = [core_span(f)[0] for i, f in enumerate(out) if i != 8]
+ head, tail = float(np.mean(got[:4])), float(np.mean(got[-4:]))
+ assert abs(tail / head - 1) < 0.08, (
+ f"有空帧时补偿被整段跳过,出帧仍在单调变大:首 {head:.0f} → 尾 {tail:.0f}"
+ f"({(tail/head-1)*100:+.0f}%)"
+ )
+
+
+def test_empty_frames_do_not_shift_the_trend_timeline():
+ """空帧不参与拟合,系数取 1.0,其余帧的系数与它不在时一致。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ full = _REAL_WALK_SPANS
+ holed = list(full)
+ holed[8] = None
+
+ ref, _ = scale_drift(full)
+ comp, ratio = scale_drift(holed)
+ assert ratio > 0.15, "少一个观测不该让 20% 的漂移判不出来"
+ assert comp[8] == 1.0, "空帧的系数应为 1.0"
+ for i, (c, r) in enumerate(zip(comp, ref, strict=True)):
+ if i != 8:
+ assert abs(c - r) < 0.01, f"第 {i} 帧系数被空洞带偏: {c:.3f} vs {r:.3f}"
+
+
+def test_align_actually_applies_the_compensation():
+ """钉的是"补偿真的接上了",不是"函数算得对"。
+
+ 只测 ``scale_drift`` 的话,把 ``align_bottom_center`` 里那一行乘法删掉,用例照样全绿
+ (变异测试逮到过)—— 那正是本仓最忌讳的"看起来成功的错结果"。
+ """
+ from windup_ai_engine.postprocess.pack import align_bottom_center, core_span
+
+ out = align_bottom_center(_drifting_bodies(), cell=256)
+ got = [core_span(f)[0] for f in out]
+ head, tail = float(np.mean(got[:4])), float(np.mean(got[-4:]))
+ assert abs(tail / head - 1) < 0.08, (
+ f"出帧后仍在单调变大:首 {head:.0f} → 尾 {tail:.0f}({(tail/head-1)*100:+.0f}%)"
+ )
+
+
+# ── 推镜 vs 真实姿态:只有高宽一起变才算漂移 ────────────────────────────────
+
+
+def _spans_seq(heights, widths):
+ """构造 (高, 宽) 序列,喂给 scale_drift 的两个入参。"""
+ return list(heights), list(widths)
+
+
+def test_camera_zoom_is_compensated():
+ """高宽同比放大 = 推镜,照旧补偿。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ n = 16
+ h = [60 + 40 * i / (n - 1) for i in range(n)]
+ w = [30 + 20 * i / (n - 1) for i in range(n)] # 与高同比例
+ comp, ratio = scale_drift(h, w)
+ assert ratio > 0.5
+ assert any(c != 1.0 for c in comp), "等比放大是推镜,必须补偿"
+
+
+def test_pose_change_is_not_compensated():
+ """深蹲→起跳:高从 60 涨到 100 而宽不动,是真实姿态,不能补偿。
+
+ 补偿它会把高度拉平的同时按同一系数缩宽,角色沿动作被压扁 —— 这正是本判据要挡的。
+ """
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ n = 16
+ h = [60 + 40 * i / (n - 1) for i in range(n)]
+ w = [30.0] * n # 宽度不动
+ comp, ratio = scale_drift(h, w)
+ assert ratio > 0.5, "高度趋势确实存在,判据不是靠 ratio 门槛挡掉的"
+ assert all(c == 1.0 for c in comp), "宽度没跟着变,不该当推镜补偿"
+
+
+def test_opposite_trends_are_not_compensated():
+ """高涨宽缩 = 姿态在拉伸,不是推镜。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ n = 16
+ h = [60 + 40 * i / (n - 1) for i in range(n)]
+ w = [40 - 10 * i / (n - 1) for i in range(n)]
+ comp, _ = scale_drift(h, w)
+ assert all(c == 1.0 for c in comp)
+
+
+def test_missing_widths_falls_back_to_old_behaviour():
+ """量不到宽度时退回旧行为,不因为少一个观测就整段不补。"""
+ from windup_ai_engine.postprocess.pack import scale_drift
+
+ n = 16
+ h = [60 + 40 * i / (n - 1) for i in range(n)]
+ comp, _ = scale_drift(h) # 不传 widths
+ assert any(c != 1.0 for c in comp)
diff --git a/backend/tests/test_pixelate.py b/backend/tests/test_pixelate.py
new file mode 100644
index 00000000..11e5bdae
--- /dev/null
+++ b/backend/tests/test_pixelate.py
@@ -0,0 +1,103 @@
+"""像素化后处理测试(纯 CV,无需联网 / API)。"""
+
+import numpy as np
+from PIL import Image
+
+from windup_ai_engine.postprocess import (
+ pixelate_frames,
+ sprite_sheet,
+ to_pixel_art,
+)
+
+
+def _synthetic_char(size=256, box=(80, 40, 176, 220)) -> Image.Image:
+ """透明底上画一个不透明矩形"角色",四周留透明边。"""
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ arr = np.asarray(img).copy()
+ x0, y0, x1, y1 = box
+ arr[y0:y1, x0:x1] = (200, 60, 60, 255)
+ # 加一点颜色变化,让色板量化有意义
+ arr[y0:y1, x0 : (x0 + x1) // 2] = (60, 120, 200, 255)
+ return Image.fromarray(arr, "RGBA")
+
+
+def test_to_pixel_art_targets_height_and_keeps_ratio():
+ src = _synthetic_char() # 主体 96x180
+ out = to_pixel_art(src, target_h=60, palette_size=16)
+ assert out.height == 60
+ # 主体宽高比 96/180 → 目标宽 ≈ 60*96/180 = 32
+ assert abs(out.width - 32) <= 1
+ assert out.mode == "RGBA"
+
+
+def test_to_pixel_art_crops_to_alpha_bbox():
+ """输出应裁到主体包围盒:透明边被切掉,首列即主体。"""
+ out = to_pixel_art(_synthetic_char(), target_h=90, palette_size=16)
+ alpha = np.asarray(out)[:, :, 3]
+ assert alpha.max() == 255 # 有实心主体
+ # 顶行与左列应落在主体上(已裁边),而非全透明
+ assert alpha[0, :].max() > 0
+ assert alpha[:, 0].max() > 0
+
+
+def test_to_pixel_art_reduces_palette():
+ out = to_pixel_art(_synthetic_char(), target_h=80, palette_size=8)
+ rgb = np.asarray(out.convert("RGB")).reshape(-1, 3)
+ colors = np.unique(rgb, axis=0)
+ assert len(colors) <= 8
+
+
+def test_pixelate_frames_uniform_height_packs_to_sheet():
+ frames = pixelate_frames([_synthetic_char() for _ in range(4)], target_h=48, palette_size=16)
+ assert all(f.height == 48 for f in frames)
+ sheet = sprite_sheet(frames)
+ assert sheet.height == 48
+ assert sheet.width == sum(f.width for f in frames)
+
+
+def test_to_pixel_art_rejects_bad_height():
+ import pytest
+
+ with pytest.raises(ValueError):
+ to_pixel_art(_synthetic_char(), target_h=0)
+
+
+def _pixel_art(block=8, logical_h=20, bg=(255, 255, 255)) -> Image.Image:
+ """合成像素画:每个逻辑像素放大成 block×block 方块,白底(模拟母版)。"""
+ colors = [(200, 60, 60), (60, 120, 200), (40, 160, 90)]
+ small = np.full((logical_h, logical_h // 2, 3), bg, dtype=np.uint8)
+ for y in range(4, logical_h - 4):
+ for x in range(2, logical_h // 2 - 2):
+ small[y, x] = colors[(x + y) % len(colors)]
+ img = Image.fromarray(small, "RGB").resize(
+ (small.shape[1] * block, logical_h * block), Image.NEAREST
+ )
+ return img.convert("RGBA")
+
+
+def test_detect_pixel_size_finds_block():
+ from windup_ai_engine.postprocess import detect_pixel_size
+
+ assert detect_pixel_size(_pixel_art(block=8)) == 8
+ assert detect_pixel_size(_pixel_art(block=12)) == 12
+
+
+def test_master_pixel_spec_gives_logical_height_and_palette():
+ from windup_ai_engine.postprocess import master_pixel_spec
+
+ logical_h, palette = master_pixel_spec(_pixel_art(block=8, logical_h=20))
+ assert 10 <= logical_h <= 14 # 主体(去掉白边)约 12 个逻辑像素高
+ assert 2 <= len(palette) <= 32
+ # 色板不应被白底/抗锯齿近白色占据
+ assert not (palette.astype(int).sum(axis=1) > 700).all()
+
+
+def test_palette_lock_restricts_output_colors():
+ """锁色板后,输出颜色必须全部来自给定色板(用于消掉压缩灰颗粒)。"""
+ palette = np.array([[200, 60, 60], [60, 120, 200]], dtype=np.uint8)
+ noisy = _synthetic_char()
+ out = to_pixel_art(noisy, target_h=24, palette=palette)
+ rgb = np.asarray(out.convert("RGB")).reshape(-1, 3)
+ used = np.unique(rgb, axis=0)
+ for c in used:
+ assert (c == palette).all(axis=1).any(), f"{c} 不在色板内"
diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py
index 957ddb75..8b797ba1 100644
--- a/backend/tests/test_project_api.py
+++ b/backend/tests/test_project_api.py
@@ -30,6 +30,7 @@ def test_create_success(auth_client):
assert body["code"] == 200
assert body["message"] == "创建成功"
assert body["data"]["id"] is not None
+ assert "user_id" not in body["data"]
assert body["data"]["project_name"] == "新建"
assert body["data"]["create_at"]
assert "timestamp" not in body
@@ -57,7 +58,9 @@ def test_create_validation_error_returns_400(auth_client):
def test_get_success(auth_client):
- created = auth_client.post("/projects", json=_payload(project_name="详情")).json()["data"]
+ created = auth_client.post("/projects", json=_payload(project_name="详情")).json()[
+ "data"
+ ]
resp = auth_client.get(f"/projects/{created['id']}")
assert resp.json()["code"] == 200
@@ -97,14 +100,16 @@ def test_list_paginates(auth_client):
assert body["total"] == 3
assert len(body["data"]) == 2
assert [item["project_name"] for item in body["data"]] == ["a2", "a1"]
- assert all(item["user_id"] == 1 for item in body["data"])
+ assert all("user_id" not in item for item in body["data"])
# -- DELETE /projects/{id} ---------------------------------------------------
def test_delete_success(auth_client):
- created = auth_client.post("/projects", json=_payload(project_name="删除")).json()["data"]
+ created = auth_client.post("/projects", json=_payload(project_name="删除")).json()[
+ "data"
+ ]
resp = auth_client.delete(f"/projects/{created['id']}")
body = resp.json()
@@ -117,3 +122,59 @@ def test_delete_not_found_returns_404(auth_client):
resp = auth_client.delete("/projects/99999")
assert resp.json()["code"] == 404
+
+
+def test_delete_rejected_when_project_has_characters(auth_client):
+ created = auth_client.post("/projects", json=_payload(project_name="有角色")).json()[
+ "data"
+ ]
+ character = auth_client.post(
+ "/characters",
+ json={
+ "project_id": created["id"],
+ "workflow_run_id": 348,
+ "name": "挂载角色",
+ "description": "阻止删项目",
+ },
+ ).json()
+
+ assert character["code"] == 200
+
+ resp = auth_client.delete(f"/projects/{created['id']}")
+
+ body = resp.json()
+ assert body["code"] == 400
+ assert body["message"] == "项目下仍有角色,无法删除"
+ assert body["data"] is None
+ assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200
+ assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200
+
+
+def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, monkeypatch):
+ """模拟检查与删除之间插入角色:应用层已看见空项目,数据库仍应拦住删除。"""
+ created = auth_client.post("/projects", json=_payload(project_name="竞态")).json()[
+ "data"
+ ]
+ character = auth_client.post(
+ "/characters",
+ json={
+ "project_id": created["id"],
+ "workflow_run_id": 349,
+ "name": "后插入",
+ "description": "检查之后才出现",
+ },
+ ).json()
+ assert character["code"] == 200
+
+ monkeypatch.setattr(
+ "windup_app.web.api.project.character_service.project_has_characters",
+ lambda session, project_id: False,
+ )
+
+ resp = auth_client.delete(f"/projects/{created['id']}")
+
+ body = resp.json()
+ assert body["code"] == 400
+ assert body["message"] == "项目下仍有角色,无法删除"
+ assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200
+ assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200
diff --git a/backend/tests/test_prompt_assets.py b/backend/tests/test_prompt_assets.py
new file mode 100644
index 00000000..50b91d3f
--- /dev/null
+++ b/backend/tests/test_prompt_assets.py
@@ -0,0 +1,201 @@
+"""提示词 markdown 资产的加载契约(#233)。
+
+这一片锁的是一个具体的失败形态,不是"文件能读到":
+**md 没打进 wheel → 提示词变空串 → 付费 i2v 调用照常发出 → 产出垃圾 → 任务显示成功。**
+帧数、时长、成色全部正常,没有任何一道会红。所以缺文件 / 缺节 / 空节都必须当场抛错。
+"""
+from __future__ import annotations
+
+import pathlib
+import shutil
+import subprocess
+import zipfile
+
+import pytest
+
+from windup_ai_engine.master_prep import ATTACK_MASTER_POSES, MASTER_POSES
+from windup_ai_engine.prompt import (
+ build_attack_prompt,
+ build_idle_prompt,
+ build_jump_prompt,
+ build_walk_prompt,
+)
+from windup_ai_engine.prompt._md import PromptAssetError, load_doc, load_section
+from windup_common.models import AttackArchetype
+
+BUILDERS = {
+ "walk": build_walk_prompt,
+ "jump": build_jump_prompt,
+ "idle": build_idle_prompt,
+ "attack": build_attack_prompt,
+}
+
+# 每份 md 里必须真的有内容的节。attack 的节名是 `<运动拓扑>.<朝向>`。
+SECTIONS = {
+ doc: ["side", "front"] for doc in ("walk.md", "jump.md", "idle.md")
+} | {
+ "attack.md": [f"{a.value}.{f}" for a in AttackArchetype for f in ("side", "front")],
+}
+
+
+# ── ① 缺失 / 空 必须当场抛错,不返回空串 ──────────────────────────────────
+
+
+def test_missing_document_raises_with_where_to_look():
+ with pytest.raises(PromptAssetError) as e:
+ load_doc("no_such_action.md")
+ assert "windup_ai_engine.prompt.prompts" in str(e.value), "报错没说去哪找"
+
+
+def test_missing_section_lists_what_is_available():
+ with pytest.raises(PromptAssetError) as e:
+ load_section("walk.md", "sideways")
+ assert "side" in str(e.value) and "front" in str(e.value)
+
+
+def _inline(monkeypatch, tmp_path, text: str):
+ """让加载器去读一份临时 md,并绕开 lru_cache(它按文件名缓存,会串用例)。"""
+ from windup_ai_engine.prompt import _md
+
+ doc = tmp_path / "inline.md"
+ doc.write_text(text, encoding="utf-8")
+ monkeypatch.setattr(_md, "load_doc", _md.load_doc.__wrapped__)
+ monkeypatch.setattr(
+ _md, "resources",
+ type("_R", (), {"files": staticmethod(
+ lambda pkg: type("_F", (), {"joinpath": staticmethod(lambda n: doc)})()
+ )}),
+ )
+ return _md
+
+
+def test_empty_section_raises_instead_of_returning_an_empty_prompt(tmp_path, monkeypatch):
+ """空节 → 抛错。这是本文件存在的首要理由:空提示词会照常发出付费调用。"""
+ md = _inline(monkeypatch, tmp_path,
+ "# 理由写在这\n\n## side\n\n## front\n\n```text\nreal text here\n```\n")
+ with pytest.raises(PromptAssetError, match="是空的"):
+ md.load_section("inline.md", "side")
+ assert md.load_section("inline.md", "front") == "real text here"
+
+
+def test_empty_section_is_allowed_only_when_asked_for(tmp_path, monkeypatch):
+ md = _inline(monkeypatch, tmp_path, "## walk\n\n## jump\n\nsomething\n")
+ assert md.load_section("inline.md", "walk", allow_empty=True) == ""
+
+
+def test_document_without_any_section_raises(tmp_path, monkeypatch):
+ """只有散文、没有 `## 节` 的文件要炸 —— 否则每个朝向都查不到,退化成缺节报错,
+ 而真正的病因(文件结构写错了)被埋掉。"""
+ md = _inline(monkeypatch, tmp_path, "# 只有理由,忘了写节\n\n随便一段散文。\n")
+ with pytest.raises(PromptAssetError, match="一个 `## <节名>` 都没有"):
+ md.load_doc("inline.md")
+
+
+def test_section_without_a_code_fence_counts_as_empty(tmp_path, monkeypatch):
+ """只有散文、没有代码块 = 空节:框外的一律不算数。"""
+ md = _inline(monkeypatch, tmp_path,
+ "## side\n\n> 这行是给人看的\n这段也是说明,没有代码块。\n")
+ with pytest.raises(PromptAssetError, match="是空的"):
+ md.load_section("inline.md", "side")
+ assert md.load_section("inline.md", "side", allow_empty=True) == ""
+
+
+def test_prose_outside_the_code_fence_is_not_part_of_the_prompt(tmp_path, monkeypatch):
+ """节外的散文一个字都不该进提示词。"""
+ md = _inline(monkeypatch, tmp_path,
+ "## side\n\n这段中文是写给人看的理由,绝不能进提示词。\n\n"
+ "```text\nactual prompt text\n```\n\n后面这段也是说明。\n")
+ assert md.load_section("inline.md", "side") == "actual prompt text"
+
+
+def test_two_code_fences_in_one_section_raise(tmp_path, monkeypatch):
+ """一节两个代码块要炸:否则就得定"哪个才算正文"的规则,那是第二真相源的开头。"""
+ md = _inline(monkeypatch, tmp_path,
+ "## side\n\n```text\nfirst\n```\n\n```text\nsecond\n```\n")
+ with pytest.raises(PromptAssetError, match="个代码块"):
+ md.load_section("inline.md", "side")
+
+
+@pytest.mark.parametrize("doc", ["walk.md", "jump.md", "idle.md", "attack.md", "master_poses.md"])
+def test_every_shipped_document_keeps_prose_and_data_separated(doc: str):
+ """随包发的每一份 md,框内不含中文。"""
+ from windup_ai_engine.prompt._md import load_doc
+
+ for section, text in load_doc(doc).items():
+ assert not any("\u4e00" <= c <= "\u9fff" for c in text), \
+ f"{doc} 的 `## {section}` 提示词正文里混进了中文:{text[:60]!r}"
+
+
+# ── ② 每个动作的每个朝向都真的有内容 ─────────────────────────────────────
+
+
+@pytest.mark.parametrize("action", sorted(BUILDERS))
+@pytest.mark.parametrize("facing", ["side", "front"])
+def test_every_action_document_has_both_facings(action: str, facing: str):
+ text = BUILDERS[action](facing)
+ assert text and len(text) > 80, f"{action}.{facing} 的提示词短得不像正文:{text!r}"
+
+
+@pytest.mark.parametrize("action", sorted(BUILDERS))
+def test_prose_before_the_sections_never_leaks_into_the_prompt(action: str):
+ """节外的说明是写给人看的,一个字都不该混进送去生成的文本。"""
+ for facing in ("side", "front"):
+ text = BUILDERS[action](facing)
+ assert "#" not in text, f"{action}.{facing} 混进了 markdown 标题"
+ assert not any("\u4e00" <= c <= "\u9fff" for c in text), \
+ f"{action}.{facing} 混进了中文说明"
+
+
+def test_illegal_facing_raises_instead_of_falling_back_to_front():
+ """非法朝向要炸。静默落到 front 会拿到一段正面走的视频而没有任何报错。"""
+ for build in BUILDERS.values():
+ with pytest.raises(ValueError):
+ build("sidee")
+
+
+# ── ③ MASTER_POSES:空是有意义的,但只有它允许 ────────────────────────────
+
+
+def test_master_poses_keeps_its_intentional_blanks():
+ assert MASTER_POSES["walk"] == "" and MASTER_POSES["run"] == ""
+ assert MASTER_POSES["idle"] == ""
+ assert "deep crouch" in MASTER_POSES["jump"]
+
+
+def test_every_attack_archetype_has_its_own_master_pose():
+ """四支的起手姿态互不兼容,缺一支就该炸 —— 母版姿态决定动作,不能退回中性站立。"""
+ assert set(ATTACK_MASTER_POSES) == set(AttackArchetype)
+ texts = [t.strip() for t in ATTACK_MASTER_POSES.values()]
+ assert all(texts), f"有空的攻击母版姿态:{ATTACK_MASTER_POSES}"
+ assert len(set(texts)) == len(texts), "有两支共用同一段母版姿态"
+
+
+def test_only_master_poses_may_be_empty():
+ """``allow_empty`` 是给"空本身有含义"的地方开的口子,别的提示词不许走这条。"""
+ for doc, sections in SECTIONS.items():
+ for section in sections:
+ assert load_section(doc, section), f"{doc} 的 {section} 空了"
+
+
+# ── ④ 打包:md 必须真的进 wheel,不能只在源码树里存在 ─────────────────────
+
+
+def test_markdown_assets_are_shipped_in_the_wheel(tmp_path):
+ """从构建出来的 wheel 里读:源码树能读到不代表装出来能读到。"""
+ if shutil.which("uv") is None:
+ pytest.skip("本机没有 uv,构建不了 wheel")
+ # 按 __file__ 锚定,不用相对路径:``cwd="packages/ai_engine"`` 隐含假设 pytest 从
+ # backend/ 起跑,换个 rootdir(仓库根 / IDE 配的)就 NotADirectoryError,
+ # 而那时测试失败的原因与被测的东西毫无关系。本文件自己的主张就是别依赖这种假设。
+ pkg = pathlib.Path(__file__).resolve().parents[1] / "packages" / "ai_engine"
+ assert pkg.is_dir(), f"定位不到 ai_engine 包:{pkg}"
+ r = subprocess.run(
+ ["uv", "build", "--wheel", "--out-dir", str(tmp_path)],
+ cwd=pkg, capture_output=True, text=True,
+ )
+ assert r.returncode == 0, f"wheel 构建失败:{r.stderr[-500:]}"
+ wheels = sorted(tmp_path.glob("*.whl"))
+ assert wheels, "没产出 wheel"
+ names = set(zipfile.ZipFile(wheels[-1]).namelist())
+ for md in ("walk.md", "jump.md", "idle.md", "attack.md", "master_poses.md"):
+ assert f"windup_ai_engine/prompt/prompts/{md}" in names, f"{md} 没进 wheel"
diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py
new file mode 100644
index 00000000..775c8176
--- /dev/null
+++ b/backend/tests/test_quota.py
@@ -0,0 +1,841 @@
+"""积分模块测试。
+
+覆盖场景:
+1. Service 层:余额查询、预付费冻结/扣减/解冻、入账、流水查询、边界异常
+2. API 层:余额端点、流水端点、无账户时 404、分页参数
+"""
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from windup_common.enums.quota import CreditReason
+from windup_framework.config.quota import settings as quota_settings
+from windup_common.exceptions import BizException
+
+from windup_app.server.quota.model import CreditAccount, CreditTransaction
+from windup_app.server.quota.service import SqlAlchemyQuotaService
+
+
+@pytest.fixture()
+def quota_service():
+ return SqlAlchemyQuotaService()
+
+
+@pytest.fixture()
+def user_with_account(db_session: Session):
+ """创建一个带积分账户的测试用户(user_id=1,与 auth_client token 对应)。"""
+ from windup_app.server.user.model import User
+
+ user = User(id=1, email="quota_test@example.com", password_hash="")
+ db_session.add(user)
+ db_session.flush()
+
+ account = CreditAccount(
+ user_id=user.id,
+ balance=quota_settings.register_gift_amount,
+ frozen=0,
+ total_earned=quota_settings.register_gift_amount,
+ total_spent=0,
+ )
+ db_session.add(account)
+ db_session.flush()
+
+ return user
+
+
+@pytest.fixture()
+def auth_quota_client(engine, user_with_account):
+ """带认证且预置积分账户的 TestClient。"""
+ from fastapi.testclient import TestClient
+ from sqlalchemy.orm import sessionmaker
+
+ from windup_app.bootstrap.app import create_app
+ from windup_app.server.user.service import create_access_token
+ from windup_framework.db import get_session
+
+ session_local = sessionmaker(bind=engine, expire_on_commit=False)
+
+ def override_get_session():
+ session = session_local()
+ try:
+ yield session
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+ finally:
+ session.close()
+
+ app = create_app()
+ app.dependency_overrides[get_session] = override_get_session
+
+ token = create_access_token(1, "quota_test@example.com")
+ client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
+
+ yield client
+ app.dependency_overrides.clear()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Service 层测试
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+# -- 余额查询 ---------------------------------------------------------------
+
+
+class TestGetAccount:
+ def test_get_existing_account(self, db_session, quota_service, user_with_account):
+ view = quota_service.get_account(db_session, user_with_account.id)
+ assert view is not None
+ assert view.balance == quota_settings.register_gift_amount
+ assert view.frozen == 0
+ assert view.total_earned == quota_settings.register_gift_amount
+ assert view.total_spent == 0
+ assert view.user_id == user_with_account.id
+
+ def test_get_nonexistent_account(self, db_session, quota_service):
+ view = quota_service.get_account(db_session, 99999)
+ assert view is None
+
+
+# -- 预付费:冻结 -----------------------------------------------------------
+
+
+class TestReserveCredit:
+ def test_reserve_success(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:1"
+ )
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert (
+ account.balance
+ == quota_settings.register_gift_amount - quota_settings.generate_image_cost
+ )
+ assert account.frozen == quota_settings.generate_image_cost
+
+ def test_reserve_insufficient(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ with pytest.raises(BizException, match="积分不足"):
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.register_gift_amount + 1, "task:2"
+ )
+
+ def test_reserve_nonexistent_account(self, db_session, quota_service):
+ with pytest.raises(BizException, match="积分账户不存在"):
+ quota_service.reserve_credit(db_session, 99999, 10, "task:x")
+
+ def test_reserve_writes_txn(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:3"
+ )
+
+ txn = db_session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.user_id == uid, CreditTransaction.ref_id == "task:3"
+ )
+ )
+ assert txn is not None
+ assert txn.delta == -quota_settings.generate_image_cost
+ assert txn.reason == CreditReason.FROZEN
+ assert txn.billing_mode == 0 # PREPAID
+
+
+# -- 预付费:扣减 -----------------------------------------------------------
+
+
+class TestCaptureCredit:
+ def test_capture_full(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ cost = quota_settings.generate_image_cost
+ quota_service.reserve_credit(db_session, uid, cost, "task:3")
+ quota_service.capture_credit(db_session, uid, cost, "task:3", cost)
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount - cost
+ assert account.frozen == 0
+ assert account.total_spent == cost
+
+ def test_capture_partial_refund(self, db_session, quota_service, user_with_account):
+ """冻结 50,实际扣 30,差额 20 退回。"""
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_action_cost, "task:4"
+ )
+ quota_service.capture_credit(
+ db_session, uid, 30, "task:4", quota_settings.generate_action_cost
+ )
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount - 30
+ assert account.frozen == 0
+ assert account.total_spent == 30
+
+ def test_capture_writes_txn_and_refund(
+ self, db_session, quota_service, user_with_account
+ ):
+ """有差额退回时应写两条流水:扣减 + 退款。"""
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_action_cost, "task:5"
+ )
+ quota_service.capture_credit(
+ db_session, uid, 30, "task:5", quota_settings.generate_action_cost
+ )
+
+ txns = db_session.scalars(
+ select(CreditTransaction).where(CreditTransaction.user_id == uid)
+ ).all()
+ reasons = [t.reason for t in txns]
+ assert CreditReason.CAPTURED in reasons
+ assert CreditReason.REFUND in reasons
+
+ def test_capture_insufficient_frozen(
+ self, db_session, quota_service, user_with_account
+ ):
+ """冻结额度不足时应抛异常。"""
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:6"
+ )
+ with pytest.raises(BizException, match="冻结额度不足"):
+ quota_service.capture_credit(db_session, uid, 100, "task:6", 100)
+
+ def test_capture_nonexistent_account(self, db_session, quota_service):
+ with pytest.raises(BizException, match="积分账户不存在"):
+ quota_service.capture_credit(db_session, 99999, 10, "task:x", 10)
+
+
+# -- 预付费:解冻 -----------------------------------------------------------
+
+
+class TestReleaseCredit:
+ def test_release_success(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:7"
+ )
+ quota_service.release_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:7"
+ )
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount
+ assert account.frozen == 0
+
+ def test_release_writes_txn(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:8"
+ )
+ quota_service.release_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:8"
+ )
+
+ txn = db_session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.user_id == uid,
+ CreditTransaction.reason == CreditReason.REFUND,
+ )
+ )
+ assert txn is not None
+ assert txn.delta == quota_settings.generate_image_cost
+
+ def test_release_insufficient_frozen(
+ self, db_session, quota_service, user_with_account
+ ):
+ uid = user_with_account.id
+ with pytest.raises(BizException, match="冻结额度不足"):
+ quota_service.release_credit(db_session, uid, 100, "task:9")
+
+ def test_release_nonexistent_account(self, db_session, quota_service):
+ with pytest.raises(BizException, match="积分账户不存在"):
+ quota_service.release_credit(db_session, 99999, 10, "task:x")
+
+
+# -- 入账 -------------------------------------------------------------------
+
+
+class TestCredit:
+ def test_credit_success(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.credit(db_session, uid, 50, CreditReason.ADMIN_ADJUST, "admin:1")
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount + 50
+ assert account.total_earned == quota_settings.register_gift_amount + 50
+
+ def test_credit_writes_txn(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.credit(db_session, uid, 50, CreditReason.ADMIN_ADJUST, "admin:2")
+
+ txn = db_session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.user_id == uid, CreditTransaction.ref_id == "admin:2"
+ )
+ )
+ assert txn is not None
+ assert txn.delta == 50
+ assert txn.reason == CreditReason.ADMIN_ADJUST
+
+ def test_credit_zero_noop(self, db_session, quota_service, user_with_account):
+ """amount <= 0 时不做任何操作。"""
+ uid = user_with_account.id
+ quota_service.credit(db_session, uid, 0, CreditReason.ADMIN_ADJUST, "admin:3")
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount
+
+ def test_credit_negative_noop(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.credit(db_session, uid, -10, CreditReason.ADMIN_ADJUST, "admin:4")
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount
+
+ def test_credit_nonexistent_account(self, db_session, quota_service):
+ with pytest.raises(BizException, match="积分账户不存在"):
+ quota_service.credit(db_session, 99999, 50, CreditReason.ADMIN_ADJUST)
+
+
+# -- 流水查询 ---------------------------------------------------------------
+
+
+class TestListTransactions:
+ def test_list_empty(self, db_session, quota_service, user_with_account):
+ txns, total = quota_service.list_transactions(db_session, user_with_account.id)
+ assert total == 0
+ assert txns == []
+
+ def test_list_after_operations(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ quota_service.reserve_credit(
+ db_session, uid, quota_settings.generate_image_cost, "task:10"
+ )
+ quota_service.capture_credit(
+ db_session,
+ uid,
+ quota_settings.generate_image_cost,
+ "task:10",
+ quota_settings.generate_image_cost,
+ )
+
+ txns, total = quota_service.list_transactions(db_session, uid)
+ assert total >= 2
+ assert txns[0].ref_id == "task:10" # 最新的在前
+
+ def test_list_pagination(self, db_session, quota_service, user_with_account):
+ uid = user_with_account.id
+ for i in range(5):
+ quota_service.credit(
+ db_session, uid, 10, CreditReason.ADMIN_ADJUST, f"page:{i}"
+ )
+
+ txns_p1, total = quota_service.list_transactions(
+ db_session, uid, page=1, page_size=2
+ )
+ assert total == 5
+ assert len(txns_p1) == 2
+
+ txns_p3, _ = quota_service.list_transactions(
+ db_session, uid, page=3, page_size=2
+ )
+ assert len(txns_p3) == 1 # 最后一页只有 1 条
+
+ def test_list_other_user_empty(self, db_session, quota_service, user_with_account):
+ """查另一个用户的流水应为空。"""
+ txns, total = quota_service.list_transactions(db_session, 99999)
+ assert total == 0
+ assert txns == []
+
+
+# -- 预付费完整流程 ----------------------------------------------------------
+
+
+class TestPrepaidFlow:
+ def test_full_flow_success(self, db_session, quota_service, user_with_account):
+ """冻结 → 扣减,余额和冻结都正确。"""
+ uid = user_with_account.id
+ cost = quota_settings.generate_image_cost
+
+ quota_service.reserve_credit(db_session, uid, cost, "flow:1")
+ quota_service.capture_credit(db_session, uid, cost, "flow:1", cost)
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount - cost
+ assert account.frozen == 0
+ assert account.total_spent == cost
+
+ def test_full_flow_fail(self, db_session, quota_service, user_with_account):
+ """冻结 → 解冻,余额恢复。"""
+ uid = user_with_account.id
+ cost = quota_settings.generate_image_cost
+
+ quota_service.reserve_credit(db_session, uid, cost, "flow:2")
+ quota_service.release_credit(db_session, uid, cost, "flow:2")
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount
+ assert account.frozen == 0
+ assert account.total_spent == 0
+
+ def test_multiple_tasks(self, db_session, quota_service, user_with_account):
+ """多个任务并发冻结,互不影响。"""
+ uid = user_with_account.id
+ cost = quota_settings.generate_image_cost
+
+ quota_service.reserve_credit(db_session, uid, cost, "multi:1")
+ quota_service.reserve_credit(db_session, uid, cost, "multi:2")
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount - cost * 2
+ assert account.frozen == cost * 2
+
+ # 第一个任务成功
+ quota_service.capture_credit(db_session, uid, cost, "multi:1", cost)
+ # 第二个任务失败
+ quota_service.release_credit(db_session, uid, cost, "multi:2")
+
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == uid)
+ )
+ assert account.balance == quota_settings.register_gift_amount - cost
+ assert account.frozen == 0
+ assert account.total_spent == cost
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# API 层测试
+# ══════════════════════════════════════════════════════════════════════════════
+
+
+class TestQuotaAPI:
+ """通过 HTTP 端点测试积分模块 API。"""
+
+ def test_get_balance(self, auth_quota_client):
+ resp = auth_quota_client.get("/quota/balance")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["code"] == 200
+ assert data["data"]["balance"] == quota_settings.register_gift_amount
+ assert data["data"]["frozen"] == 0
+ assert data["data"]["total_earned"] == quota_settings.register_gift_amount
+
+ def test_get_balance_no_account(self, auth_client):
+ """没有积分账户时应返回 404。"""
+ resp = auth_client.get("/quota/balance")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["code"] == 404
+
+ def test_list_transactions_empty(self, auth_quota_client):
+ resp = auth_quota_client.get("/quota/transactions")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["code"] == 200
+ assert data["data"] == []
+ assert data["total"] == 0
+
+ def test_list_transactions_pagination(
+ self, auth_quota_client, db_session, user_with_account
+ ):
+ """先写入几条流水,再通过 API 分页查询。"""
+ uid = user_with_account.id
+ service = SqlAlchemyQuotaService()
+ for i in range(5):
+ service.credit(db_session, uid, 10, CreditReason.ADMIN_ADJUST, f"api:{i}")
+ db_session.commit()
+
+ resp = auth_quota_client.get("/quota/transactions?page=1&page_size=2")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 5
+ assert len(data["data"]) == 2
+
+ def test_list_transactions_default_pagination(
+ self, auth_quota_client, db_session, user_with_account
+ ):
+ """默认分页参数。"""
+ uid = user_with_account.id
+ service = SqlAlchemyQuotaService()
+ service.credit(db_session, uid, 10, CreditReason.ADMIN_ADJUST, "api:def")
+ db_session.commit()
+
+ resp = auth_quota_client.get("/quota/transactions")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+
+ def test_unauthenticated_access(self, client):
+ """未登录访问应返回 401。"""
+ resp = client.get("/quota/balance")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["code"] == 401
+
+
+def _gift_account(session: Session, user_id: int) -> None:
+ session.add(
+ CreditAccount(
+ user_id=user_id,
+ balance=quota_settings.register_gift_amount,
+ frozen=0,
+ total_earned=quota_settings.register_gift_amount,
+ total_spent=0,
+ )
+ )
+ session.flush()
+
+
+class TestInviteCode:
+ """邀请码生成、查询与兑换。"""
+
+ def test_get_invite_code_creates_when_missing(self, auth_quota_client):
+ resp = auth_quota_client.get("/quota/invite/code")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["code"] == 200
+ assert len(data["data"]["code"]) == 8
+ assert data["data"]["used_count"] == 0
+
+ again = auth_quota_client.get("/quota/invite/code")
+ assert again.json()["data"]["code"] == data["data"]["code"]
+ assert again.json()["data"]["expires_at"]
+
+ def test_generate_invite_code_rotates(self, auth_quota_client):
+ first = auth_quota_client.get("/quota/invite/code").json()["data"]["code"]
+ second = auth_quota_client.post("/quota/invite/generate").json()["data"]["code"]
+ assert second != first
+ assert len(second) == 8
+ assert auth_quota_client.get("/quota/invite/code").json()["data"]["code"] == second
+
+ def test_generate_invite_code_locks_existing_row(
+ self, db_session, quota_service, monkeypatch
+ ):
+ from sqlalchemy.sql.selectable import Select
+ from windup_app.server.user.model import User
+
+ host = User(email="lock-host@example.com", password_hash="x")
+ db_session.add(host)
+ db_session.flush()
+ quota_service.generate_invite_code(db_session, host.id)
+
+ locked = []
+ original = Select.with_for_update
+
+ def tracking(self, *args, **kwargs):
+ locked.append(True)
+ return original(self, *args, **kwargs)
+
+ monkeypatch.setattr(Select, "with_for_update", tracking)
+ quota_service.generate_invite_code(db_session, host.id)
+ assert locked, "轮换已有邀请码时应对该行加 FOR UPDATE"
+
+ def test_generate_invite_code_keeps_old_row(self, db_session, quota_service):
+ from datetime import datetime, timezone
+ from sqlalchemy import select
+ from windup_app.server.quota.model import InviteCode
+ from windup_app.server.user.model import User
+
+ host = User(email="append-host@example.com", password_hash="x")
+ db_session.add(host)
+ db_session.flush()
+ first = quota_service.generate_invite_code(db_session, host.id)
+ second = quota_service.generate_invite_code(db_session, host.id)
+ rows = db_session.scalars(
+ select(InviteCode).where(InviteCode.user_id == host.id)
+ ).all()
+ assert {row.code for row in rows} == {first.code, second.code}
+ old = next(row for row in rows if row.code == first.code)
+ now = datetime.now(timezone.utc)
+ exp = old.expires_at if old.expires_at.tzinfo else old.expires_at.replace(
+ tzinfo=timezone.utc
+ )
+ assert exp <= now
+
+ def test_get_invite_code_issues_new_row_after_expiry(
+ self, db_session, quota_service
+ ):
+ from datetime import datetime, timedelta, timezone
+ from sqlalchemy import select
+ from windup_app.server.quota.model import InviteCode
+ from windup_app.server.user.model import User
+
+ host = User(email="expire-host@example.com", password_hash="x")
+ db_session.add(host)
+ db_session.flush()
+ first = quota_service.generate_invite_code(db_session, host.id)
+ row = db_session.scalar(
+ select(InviteCode).where(InviteCode.code == first.code)
+ )
+ row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+ db_session.flush()
+
+ second = quota_service.get_invite_code(db_session, host.id)
+ assert second.code != first.code
+ assert (
+ db_session.scalar(
+ select(InviteCode).where(InviteCode.code == first.code)
+ )
+ is not None
+ )
+
+ def test_redeem_unique_violation_is_already_redeemed(
+ self, db_session, quota_service, monkeypatch
+ ):
+ """并发双兑时 unique(invitee_id) 应收敛为「已填写过邀请码」,而不是 500。"""
+ from sqlalchemy.exc import IntegrityError
+ from windup_app.server.user.model import User
+ from windup_common.exceptions import BizException
+
+ host = User(email="race-host@example.com", password_hash="x")
+ guest = User(email="race-guest@example.com", password_hash="x")
+ db_session.add_all([host, guest])
+ db_session.flush()
+ _gift_account(db_session, host.id)
+ _gift_account(db_session, guest.id)
+ view = quota_service.generate_invite_code(db_session, host.id)
+
+ from windup_app.server.quota.model import InviteRecord
+
+ orig_flush = db_session.flush
+
+ def boom(*_args, **_kwargs):
+ if any(isinstance(obj, InviteRecord) for obj in db_session.new):
+ raise IntegrityError(
+ "INSERT",
+ {},
+ Exception(
+ "UNIQUE constraint failed: windup_invite_record.invitee_id"
+ ),
+ )
+ return orig_flush(*_args, **_kwargs)
+
+ monkeypatch.setattr(db_session, "flush", boom)
+
+ with pytest.raises(BizException, match="已填写过邀请码"):
+ quota_service.redeem_invite_code(db_session, guest.id, view.code)
+
+ def test_redeem_invite_code_rewards_both_users(self, db_session, quota_service):
+ from windup_app.server.user.model import User
+
+ inviter = User(email="host@example.com", password_hash="x")
+ invitee = User(email="guest@example.com", password_hash="x")
+ db_session.add_all([inviter, invitee])
+ db_session.flush()
+ _gift_account(db_session, inviter.id)
+ _gift_account(db_session, invitee.id)
+ view = quota_service.generate_invite_code(db_session, inviter.id)
+
+ quota_service.redeem_invite_code(db_session, invitee.id, view.code.lower())
+
+ host = quota_service.get_account(db_session, inviter.id)
+ guest = quota_service.get_account(db_session, invitee.id)
+ assert (
+ host.balance
+ == quota_settings.register_gift_amount + quota_settings.invite_reward_amount
+ )
+ assert (
+ guest.balance
+ == quota_settings.register_gift_amount + quota_settings.invite_reward_amount
+ )
+
+ def test_inviter_daily_reward_stops_after_three_invites(
+ self, db_session, quota_service
+ ):
+ from windup_app.server.quota.model import InviteRecord
+ from windup_app.server.user.model import User
+
+ inviter = User(email="cap-host@example.com", password_hash="x")
+ db_session.add(inviter)
+ db_session.flush()
+ _gift_account(db_session, inviter.id)
+ view = quota_service.generate_invite_code(db_session, inviter.id)
+
+ guests = []
+ for i in range(4):
+ guest = User(email=f"cap-guest-{i}@example.com", password_hash="x")
+ db_session.add(guest)
+ db_session.flush()
+ _gift_account(db_session, guest.id)
+ quota_service.redeem_invite_code(db_session, guest.id, view.code)
+ guests.append(guest)
+
+ host = quota_service.get_account(db_session, inviter.id)
+ assert host.balance == quota_settings.register_gift_amount + (
+ quota_settings.invite_reward_amount * 3
+ )
+ assert (
+ db_session.scalar(
+ select(InviteRecord.id).where(
+ InviteRecord.invitee_id == guests[3].id
+ )
+ )
+ is not None
+ )
+ fourth = quota_service.get_account(db_session, guests[3].id)
+ assert (
+ fourth.balance
+ == quota_settings.register_gift_amount + quota_settings.invite_reward_amount
+ )
+
+ def test_inviter_daily_reward_resets_next_utc_day(
+ self, db_session, quota_service
+ ):
+ from datetime import timedelta
+ from windup_app.server.quota.model import InviteRecord
+ from windup_app.server.quota.service import _now
+ from windup_app.server.user.model import User
+
+ inviter = User(email="nextday-host@example.com", password_hash="x")
+ db_session.add(inviter)
+ db_session.flush()
+ _gift_account(db_session, inviter.id)
+ view = quota_service.generate_invite_code(db_session, inviter.id)
+
+ for i in range(3):
+ guest = User(email=f"old-guest-{i}@example.com", password_hash="x")
+ db_session.add(guest)
+ db_session.flush()
+ _gift_account(db_session, guest.id)
+ quota_service.redeem_invite_code(db_session, guest.id, view.code)
+
+ yesterday = _now() - timedelta(days=1)
+ for row in db_session.scalars(
+ select(InviteRecord).where(InviteRecord.inviter_id == inviter.id)
+ ).all():
+ row.create_at = yesterday
+ db_session.flush()
+
+ today_guest = User(email="today-guest@example.com", password_hash="x")
+ db_session.add(today_guest)
+ db_session.flush()
+ _gift_account(db_session, today_guest.id)
+ quota_service.redeem_invite_code(db_session, today_guest.id, view.code)
+
+ host = quota_service.get_account(db_session, inviter.id)
+ assert host.balance == quota_settings.register_gift_amount + (
+ quota_settings.invite_reward_amount * 4
+ )
+
+ def test_redeem_rejects_own_code_and_repeat(self, db_session, quota_service):
+ from windup_app.server.user.model import User
+ from windup_common.exceptions import BizException
+
+ host = User(email="self@example.com", password_hash="x")
+ guest = User(email="once@example.com", password_hash="x")
+ db_session.add_all([host, guest])
+ db_session.flush()
+ _gift_account(db_session, host.id)
+ _gift_account(db_session, guest.id)
+ view = quota_service.generate_invite_code(db_session, host.id)
+
+ with pytest.raises(BizException, match="不能填写自己的邀请码"):
+ quota_service.redeem_invite_code(db_session, host.id, view.code)
+
+ quota_service.redeem_invite_code(db_session, guest.id, view.code)
+ with pytest.raises(BizException, match="已填写过邀请码"):
+ quota_service.redeem_invite_code(db_session, guest.id, view.code)
+
+ def test_generate_invite_code_rejects_missing_user(self, db_session, quota_service):
+ from windup_common.exceptions import BizException
+
+ with pytest.raises(BizException, match="用户不存在"):
+ quota_service.generate_invite_code(db_session, 999999)
+
+ def test_allocate_invite_code_gives_up_on_collision(
+ self, db_session, quota_service, monkeypatch
+ ):
+ from windup_app.server.quota import service as quota_mod
+ from windup_app.server.user.model import User
+ from windup_common.exceptions import BizException
+
+ taken = User(email="taken@example.com", password_hash="x")
+ host = User(email="alloc@example.com", password_hash="x")
+ db_session.add_all([taken, host])
+ db_session.flush()
+ occupied = quota_service.generate_invite_code(db_session, taken.id)
+ monkeypatch.setattr(quota_mod, "_new_invite_code", lambda: occupied.code)
+
+ with pytest.raises(BizException, match="邀请码生成失败"):
+ quota_service.generate_invite_code(db_session, host.id)
+
+ def test_redeem_rejects_blank_or_unknown_code(self, db_session, quota_service):
+ from windup_app.server.user.model import User
+ from windup_common.exceptions import BizException
+
+ guest = User(email="blank@example.com", password_hash="x")
+ db_session.add(guest)
+ db_session.flush()
+ _gift_account(db_session, guest.id)
+
+ with pytest.raises(BizException, match="邀请码无效"):
+ quota_service.redeem_invite_code(db_session, guest.id, " ")
+ with pytest.raises(BizException, match="邀请码无效"):
+ quota_service.redeem_invite_code(db_session, guest.id, "IO01")
+ with pytest.raises(BizException, match="邀请码无效"):
+ quota_service.redeem_invite_code(db_session, guest.id, "NOPE1234")
+
+ def test_redeem_rejects_expired_code(self, db_session, quota_service):
+ from datetime import datetime, timedelta, timezone
+ from sqlalchemy import select
+ from windup_app.server.quota.model import InviteCode
+ from windup_app.server.user.model import User
+ from windup_common.enums.biz_code import BizCode
+ from windup_common.exceptions import BizException
+
+ host = User(email="stale-host@example.com", password_hash="x")
+ guest = User(email="stale-guest@example.com", password_hash="x")
+ db_session.add_all([host, guest])
+ db_session.flush()
+ _gift_account(db_session, host.id)
+ _gift_account(db_session, guest.id)
+ view = quota_service.generate_invite_code(db_session, host.id)
+ row = db_session.scalar(select(InviteCode).where(InviteCode.code == view.code))
+ row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
+ db_session.flush()
+
+ with pytest.raises(BizException, match="邀请码已过期") as exc:
+ quota_service.redeem_invite_code(db_session, guest.id, view.code)
+ assert exc.value.code == BizCode.NOT_FOUND
+
+ def test_redeem_rejects_missing_invitee(self, db_session, quota_service):
+ from windup_app.server.user.model import User
+ from windup_common.exceptions import BizException
+
+ host = User(email="orphan-host@example.com", password_hash="x")
+ db_session.add(host)
+ db_session.flush()
+ _gift_account(db_session, host.id)
+ view = quota_service.generate_invite_code(db_session, host.id)
+
+ with pytest.raises(BizException, match="用户不存在"):
+ quota_service.redeem_invite_code(db_session, 999999, view.code)
+
+ def test_invite_redeem_endpoint_removed(self, auth_quota_client):
+ resp = auth_quota_client.post("/quota/invite/redeem", json={"code": "AB23CD45"})
+ assert resp.status_code == 404
diff --git a/backend/tests/test_render3d_asset_endpoints.py b/backend/tests/test_render3d_asset_endpoints.py
new file mode 100644
index 00000000..8174494d
--- /dev/null
+++ b/backend/tests/test_render3d_asset_endpoints.py
@@ -0,0 +1,358 @@
+"""建 3D 资产的四个端点:看状态 / 建 / 放行 / 否掉。
+
+锁的是**钱和闸**,不是"能返回 200":
+
+ ① 按次计费的触发点只有一个,且成本恒在返回里 —— 用户不可能在不知情时触发;
+ ② 人工确认闸不点头就不绑骨。这道闸一旦能自动放行,一个坏模型会连带浪费绑骨的
+ 绑骨那笔计费和之后所有出帧,而人要看完一整套序列帧才发现锅在最上游;
+ ③ 母版没过预检就不许花钱建 —— 重出母版比重建模型便宜一个量级,错要在便宜的地方纠。
+"""
+from __future__ import annotations
+
+import io
+import pathlib
+
+import pytest
+from PIL import Image
+
+from windup_app.server.orchestrator.render3d_assets import (
+ BUILD_CREDITS,
+ LocalDirAssetStore,
+ LocalDirModelReview,
+ Render3DAssetBuilder,
+)
+from windup_app.server.orchestrator.render3d_service import Render3DAssetOperations
+from windup_app.server.character.model import Character
+from windup_app.server.project.model import Project
+
+MASTER_URL = "https://cdn.windup.test/media/reference-image/master.png"
+OUTFIT_ID = "outfit-default"
+
+
+def _master_png(*, legs_apart: bool = True) -> bytes:
+ """一个能过预检的人形。腿之间那道空隙决定 ``limb_segments``。"""
+ img = Image.new("RGBA", (200, 400), (0, 0, 0, 0))
+ px = img.load()
+
+ def block(x0, y0, x1, y1):
+ for y in range(y0, y1):
+ for x in range(x0, x1):
+ px[x, y] = (40, 40, 60, 255)
+
+ block(80, 40, 120, 100)
+ block(70, 100, 130, 240)
+ block(74, 240, 94, 380)
+ block(106, 240, 126, 380)
+ if not legs_apart:
+ block(94, 240, 106, 380)
+ buf = io.BytesIO()
+ img.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _FakeModel3D:
+ """图生 3D 的替身。**记账**:调了几次就是花了几次钱。"""
+
+ def __init__(self) -> None:
+ self.calls = 0
+
+ def image_to_3d(self, master: bytes, *, want: str = "GLB") -> bytes:
+ self.calls += 1
+ return b"glTF-fake-model"
+
+
+class _FakeAutoRig:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ def rig(self, model: bytes, *, want: str = "GLB", motion=None):
+ self.calls += 1
+ return _Rigged(model + b"-rigged", "GLB")
+
+
+class _Rigged:
+ def __init__(self, data: bytes, fmt: str) -> None:
+ self.data, self.fmt = data, fmt
+
+
+@pytest.fixture()
+def render3d(tmp_path: pathlib.Path):
+ """就地跑完的 operations —— 不起线程,用例不必等收敛。
+
+ 等线程的用例会变成偶发失败,而偶发失败最后都会被人当噪音忽略。
+ """
+ model3d, autorig = _FakeModel3D(), _FakeAutoRig()
+ store = LocalDirAssetStore(tmp_path / "assets")
+ builder = Render3DAssetBuilder(
+ model3d=model3d,
+ autorig=autorig,
+ store=store,
+ review=LocalDirModelReview(tmp_path / "review"),
+ may_build_assets=True,
+ )
+ published: list[bytes] = []
+
+ def publish(data: bytes) -> str:
+ published.append(data)
+ return f"https://cdn.windup.test/media/model-3d/{len(published)}.glb"
+
+ source = {"master": _master_png()} # 用例要换母版时改这里,不碰私有属性
+ operations = Render3DAssetOperations(
+ builder, store, publish,
+ fetch=lambda url: source["master"],
+ spawn=lambda work: work(),
+ )
+ operations.test_model3d = model3d # 用例要按住"调了几次"
+ operations.test_autorig = autorig
+ operations.test_source = source
+ return operations
+
+
+@pytest.fixture()
+def api(auth_client, engine, render3d):
+ """带一个已确认定妆母版的造型的角色 + 装好替身的 app。"""
+ auth_client.app.state.render3d_operations = render3d
+ from sqlalchemy.orm import sessionmaker
+
+ session = sessionmaker(bind=engine)()
+ session.add(Project(id=1, user_id=1, project_name="p", character_perspective=1,
+ directional_movement=1, sprite_width=64, sprite_height=64))
+ # 先落 project 再插 character:两者在同一 session 里时 SQLAlchemy 不保证插入顺序,
+ # 而 character.project_id 有真外键 —— 顺序反了就是 FOREIGN KEY constraint failed。
+ session.flush()
+ session.add(Character(
+ id=7, project_id=1, workflow_run_id=1, name="仙月",
+ character_data={"version": 1, "outfits": [
+ {"id": OUTFIT_ID, "name": "常态造型", "description": None,
+ "preview_url": MASTER_URL, "model_3d_url": None, "actions": []},
+ ]},
+ status=0,
+ ))
+ session.commit()
+ session.close()
+ return auth_client
+
+
+def _base(outfit_id: str = OUTFIT_ID) -> str:
+ return f"/render3d/characters/7/outfits/{outfit_id}"
+
+
+def _data(response) -> dict:
+ body = response.json()
+ assert body["code"] == 200, body
+ return body["data"]
+
+
+# ── ① 成本:用户不可能在不知情时触发按次计费 ──────────────────────────────
+
+
+def test_status_always_carries_the_cost_even_before_anything_is_built(api, render3d):
+ data = _data(api.get(_base()))
+ assert data["state"] == "absent"
+ assert data["cost"]["model3d_credits"] == 20
+ assert data["cost"]["autorig_credits"] == 10
+ assert data["cost"]["total_credits"] == BUILD_CREDITS
+ assert data["cost"]["total_cny"] == pytest.approx(3.60)
+ assert data["cost"]["scope"] == "per_outfit_once"
+ assert render3d.test_model3d.calls == 0 # 看一眼状态不花钱
+
+
+def test_reading_status_is_free_no_matter_how_often(api, render3d):
+ for _ in range(5):
+ api.get(_base())
+ assert render3d.test_model3d.calls == 0
+ assert render3d.test_autorig.calls == 0
+
+
+def test_cost_numbers_come_from_the_billing_implementation(api):
+ """成本不是前端抄的常量。改了计费实现而这里没跟着变,说明有人抄了一份数字 ——
+ 抄的那一份正是给用户看的,告知错的价钱比不告知更糟。"""
+ from windup_framework.providers.render3d.tencent import (
+ CREDIT_PRICE_CNY,
+ CREDITS,
+ RIG_CREDITS,
+ )
+
+ cost = _data(api.get(_base()))["cost"]
+ assert cost["model3d_credits"] == CREDITS["Normal"]
+ assert cost["autorig_credits"] == RIG_CREDITS
+ assert cost["total_cny"] == pytest.approx(
+ (CREDITS["Normal"] + RIG_CREDITS) * CREDIT_PRICE_CNY, abs=0.005
+ )
+
+
+# ── ② 人工确认闸 ────────────────────────────────────────────────────────────
+
+
+def test_build_stops_at_the_review_gate_without_rigging(api, render3d):
+ """建完 ① 就停。**绑骨一次都不许调** —— 这道闸的全部价值就在这里。"""
+ data = _data(api.post(f"{_base()}/build"))
+ assert data["state"] == "awaiting_review"
+ assert render3d.test_model3d.calls == 1
+ assert render3d.test_autorig.calls == 0
+ assert _data(api.get(_base()))["state"] == "awaiting_review"
+
+
+def test_waiting_at_the_gate_forever_never_auto_approves(api, render3d):
+ """反复查状态不会把闸熬开。超时自动放行的闸等于没有闸,只是把"没人看"伪装成
+ "看过了"。"""
+ api.post(f"{_base()}/build")
+ for _ in range(10):
+ assert _data(api.get(_base()))["state"] == "awaiting_review"
+ assert render3d.test_autorig.calls == 0
+
+
+def test_awaiting_review_hands_out_a_link_to_the_model(api):
+ """待审模型必须能取到。只躺在服务器磁盘上的话,人点"通过"时其实一眼都没看到,
+ 闸就退化成一个必须点的按钮 —— 比没有闸更糟,它制造了"已经审过"的假象。"""
+ data = _data(api.post(f"{_base()}/build"))
+ assert data["review_model_url"], "待审模型没有可打开的地址"
+ assert data["model_3d_url"] is None, "还没绑骨,不该有可用的绑骨模型"
+
+
+def test_discarding_also_drops_the_review_link(api):
+ api.post(f"{_base()}/build")
+ assert _data(api.post(f"{_base()}/discard"))["review_model_url"] is None
+
+
+def test_approve_is_what_starts_rigging(api, render3d):
+ api.post(f"{_base()}/build")
+ data = _data(api.post(f"{_base()}/approve"))
+ assert data["state"] == "ready"
+ assert render3d.test_autorig.calls == 1
+ assert data["model_3d_url"]
+
+
+def test_approving_before_there_is_a_model_is_refused(api, render3d):
+ body = api.post(f"{_base()}/approve").json()
+ assert body["code"] == 400
+ assert render3d.test_autorig.calls == 0
+
+
+def test_discard_sends_it_back_to_absent_and_the_next_build_regenerates(api, render3d):
+ """不合格 → 丢弃 → 重新生成。混元的模型改不动,这是唯一的补救。"""
+ api.post(f"{_base()}/build")
+ assert _data(api.post(f"{_base()}/discard"))["state"] == "absent"
+ assert render3d.test_autorig.calls == 0
+
+ assert _data(api.post(f"{_base()}/build"))["state"] == "awaiting_review"
+ assert render3d.test_model3d.calls == 2 # 重新生成要再付一次图生 3D
+
+
+def test_discard_after_a_failed_rig_clears_the_approval_marker(api, render3d):
+ """绑骨失败后模型还卡在闸上、而批准标记已经留下了。此时否掉必须把标记一起删 ——
+ 留着的话,下一次生成出来的新模型会被这枚旧标记直接放行,人一眼都没看到就进了绑骨。"""
+ def _boom(model: bytes, *, want: str = "GLB", motion=None):
+ render3d.test_autorig.calls += 1
+ raise RuntimeError("绑骨服务 500")
+
+ api.post(f"{_base()}/build")
+ render3d.test_autorig.rig = _boom
+ api.post(f"{_base()}/approve")
+ assert render3d.test_autorig.calls == 1
+
+ assert _data(api.post(f"{_base()}/discard"))["state"] == "absent"
+ render3d.test_autorig.rig = _FakeAutoRig().rig.__get__(render3d.test_autorig)
+ assert _data(api.post(f"{_base()}/build"))["state"] == "awaiting_review", (
+ "新模型被旧的批准标记放行了"
+ )
+
+
+# ── ③ 复用:每造型一次性,不是每动作一次 ────────────────────────────────────
+
+
+def test_building_twice_is_refused_instead_of_paying_again(api, render3d):
+ api.post(f"{_base()}/build")
+ assert api.post(f"{_base()}/build").json()["code"] == 400
+ assert render3d.test_model3d.calls == 1
+
+
+def test_ready_asset_is_not_rebuilt(api, render3d):
+ api.post(f"{_base()}/build")
+ api.post(f"{_base()}/approve")
+ assert api.post(f"{_base()}/build").json()["code"] == 400
+ assert render3d.test_model3d.calls == 1
+ assert render3d.test_autorig.calls == 1
+
+
+def test_ready_asset_writes_the_url_back_onto_the_outfit(api, engine):
+ """不回写的话,三渲二的判据(``outfits[].model_3d_url``)永远是 None ——
+ 资产建好了,前端依旧显示"该造型暂无绑骨 3D 模型",钱白花。"""
+ api.post(f"{_base()}/build")
+ api.post(f"{_base()}/approve")
+ api.get(_base()) # 回写发生在读状态这一步
+
+ from sqlalchemy.orm import sessionmaker
+
+ session = sessionmaker(bind=engine)()
+ stored = session.get(Character, 7)
+ outfit = stored.character_data["outfits"][0]
+ session.close()
+ assert outfit["model_3d_url"], "建好的模型 URL 没落到造型上"
+
+
+# ── ④ 母版预检把关花钱那一步 ────────────────────────────────────────────────
+
+
+def test_unusable_master_is_refused_before_any_paid_call(api, render3d):
+ """空白母版 → 拒绝建。重出母版比重建模型便宜一个量级,错要在便宜的地方纠。"""
+ blank = Image.new("RGBA", (200, 200), (0, 0, 0, 0))
+ buf = io.BytesIO()
+ blank.save(buf, "PNG")
+ render3d.test_source["master"] = buf.getvalue()
+
+ assert api.post(f"{_base()}/build").json()["code"] == 400
+ assert render3d.test_model3d.calls == 0
+
+
+def test_a_warned_but_usable_master_still_builds(api, render3d):
+ """警告不拦路:两条警告判据都会在合法母版上误报(侧视角色两腿必然重叠),
+ 拿它们挡路等于把误报变成挡住用户的钱。"""
+ render3d.test_source["master"] = _master_png(legs_apart=False)
+ assert _data(api.post(f"{_base()}/build"))["state"] == "awaiting_review"
+ assert render3d.test_model3d.calls == 1
+
+
+def test_precheck_endpoint_reports_facts_and_warnings_without_spending(api, render3d):
+ api.app.state.precheck_master = lambda url, canvas=None: {
+ "accepted": True, "reject_code": None, "detail": "ok",
+ "facts": {"limb_segments": [1, 1, 1, 1]},
+ "warnings": [{"code": "limbs_fused", "detail": "两腿量不到空隙"}],
+ }
+ data = _data(api.post("/render3d/master-precheck", json={"image_url": MASTER_URL}))
+ assert data["accepted"] is True
+ assert data["warnings"][0]["code"] == "limbs_fused"
+ assert render3d.test_model3d.calls == 0
+
+
+# ── ⑤ 归属与键 ──────────────────────────────────────────────────────────────
+
+
+def test_unknown_outfit_is_not_found(api):
+ assert api.get(_base("outfit-nope")).json()["code"] == 404
+
+
+def test_other_users_character_is_not_reachable(auth_client_b, api):
+ assert auth_client_b.get(_base()).json()["code"] == 404
+
+
+def test_asset_key_is_namespaced_by_character(api, render3d, engine):
+ """``outfit-default`` 是工作流写死给首个造型的 id。只用它当落点键的话,
+ 全站每个角色的默认造型会共用同一个 3D 模型,且没有任何报错。"""
+ api.post(f"{_base()}/build")
+ from sqlalchemy.orm import sessionmaker
+
+ session = sessionmaker(bind=engine)()
+ session.add(Character(
+ id=8, project_id=1, workflow_run_id=2, name="另一个",
+ character_data={"version": 1, "outfits": [
+ {"id": OUTFIT_ID, "name": "常态造型", "description": None,
+ "preview_url": MASTER_URL, "model_3d_url": None, "actions": []},
+ ]},
+ status=0,
+ ))
+ session.commit()
+ session.close()
+
+ other = api.get(f"/render3d/characters/8/outfits/{OUTFIT_ID}")
+ assert _data(other)["state"] == "absent", "另一个角色的默认造型不该套用这一个的模型"
diff --git a/backend/tests/test_render3d_checks.py b/backend/tests/test_render3d_checks.py
new file mode 100644
index 00000000..6a27bee4
--- /dev/null
+++ b/backend/tests/test_render3d_checks.py
@@ -0,0 +1,218 @@
+"""入口预检的用例。
+
+每条都对着一个具体的失败形态,而不是"覆盖率"。重点是那三条**违反了接口不报错、
+只默默出错结果**的硬约束 —— 它们只能在这一层被拦住。
+"""
+from __future__ import annotations
+
+import pytest
+
+from windup_framework.providers.render3d import ModelRejectCode, ModelRejected, check_model, sniff_format
+from windup_framework.providers.render3d.checks import ARM_SPAN_MAX, ARM_SPAN_MIN, MAX_UPLOAD_BYTES, read_glb_geometry
+
+from render3d_helpers import make_glb
+
+
+# ── 格式嗅探 ────────────────────────────────────────────────────────────────
+
+
+def test_sniff_glb_and_fbx(decimated_glb, rigged_fbx):
+ assert sniff_format(decimated_glb) == "GLB"
+ assert sniff_format(rigged_fbx) == "FBX"
+
+
+def test_sniff_rejects_garbage():
+ with pytest.raises(ModelRejected) as e:
+ sniff_format(b"PK\x03\x04this is a zip")
+ assert e.value.code == ModelRejectCode.UNREADABLE
+
+
+def test_sniff_rejects_obj_zip_masquerading():
+ """踩过的坑:一次任务返回多个格式,取到 OBJ 的 zip 被按 .glb 存下。
+ 嗅探必须认出它不是 GLB —— 那一次是靠 Blender 报 "Bad glTF" 才发现的。"""
+ with pytest.raises(ModelRejected):
+ sniff_format(b"PK\x03\x04" + b"\x00" * 100)
+
+
+# ── 体积 ────────────────────────────────────────────────────────────────────
+
+
+def test_real_decimated_glb_passes(decimated_glb):
+ """已知良品控制样本:这份档真的送进绑骨并成功了。它必须过检 ——
+ 过不了说明闸门定错了,而不是模型有问题(先验仪器)。"""
+ facts = check_model(decimated_glb)
+ assert facts.fmt == "GLB"
+ assert facts.geometry_read
+ assert facts.mesh_nodes == 1
+ assert 0.70 < facts.arm_span_ratio < 0.73 # 实测 0.715
+ assert ARM_SPAN_MIN < facts.arm_span_ratio < ARM_SPAN_MAX
+
+
+def test_oversize_is_rejected():
+ big = make_glb(pad=MAX_UPLOAD_BYTES + 1000)
+ assert len(big) > MAX_UPLOAD_BYTES
+ with pytest.raises(ModelRejected) as e:
+ check_model(big)
+ assert e.value.code == ModelRejectCode.TOO_LARGE
+ assert "decimate" in e.value.message # 错误里要写清怎么修
+
+
+def test_size_limit_is_10e6_not_2e20():
+ """60MB 按 10^6 算(取小的那个口径)。刚好落在两种口径之间的档必须被拒 ——
+ 宁可多拒 4.7%,也不要送上去以后默默产出错结果。"""
+ between = 60 * 10**6 + 5000 # < 60*2^20,> 60*10^6
+ assert between < 60 * 2**20
+ with pytest.raises(ModelRejected):
+ check_model(make_glb(pad=between))
+
+
+def test_size_checked_before_geometry():
+ """超大且几何也坏的档,报的必须是体积 —— 体积是精确判据,几何是近似判据,
+ 先报精确的那条,免得把人引去改姿势。"""
+ huge_and_narrow = make_glb((-0.05, 0.0, -0.05), (0.05, 1.0, 0.05),
+ pad=MAX_UPLOAD_BYTES + 1000)
+ with pytest.raises(ModelRejected) as e:
+ check_model(huge_and_narrow)
+ assert e.value.code == ModelRejectCode.TOO_LARGE
+
+
+# ── 姿势(单侧近似) ────────────────────────────────────────────────────────
+
+
+def test_arms_down_is_rejected():
+ """手臂贴身垂下(臂展≈肩宽 0.24H):明显不是 A/T-Pose。
+
+ 注意判据是 ``max(X, Z) / Y`` —— 横向取两条水平轴里大的那条,因为角色朝向未知。
+ 所以造用例时进深也得压窄,否则量到的是进深而不是臂展(第一版用例就栽在这儿)。
+ """
+ with pytest.raises(ModelRejected) as e:
+ check_model(make_glb((-0.12, 0.0, -0.05), (0.12, 1.0, 0.05)))
+ assert e.value.code == ModelRejectCode.NOT_A_POSE
+ assert "0.24" in e.value.message # 量到的数要写在错误里,便于反查误拒
+
+
+def test_t_pose_passes():
+ """T-Pose 臂展 ≈ 身高,必须过。"""
+ facts = check_model(make_glb((-0.5, 0.0, -0.14), (0.5, 1.0, 0.14)))
+ assert facts.arm_span_ratio == pytest.approx(1.0)
+
+
+def test_lying_down_or_wrong_axis_is_rejected():
+ """身高被放到 Z 轴上(从 Z-up 工具导出):臂展/身高 冲到 3 以上。
+ 这一条挡的是坐标系错了,后果与姿势错一样 —— 照样扣积分、照样出错结果。"""
+ with pytest.raises(ModelRejected) as e:
+ check_model(make_glb((-0.35, 0.0, 0.0), (0.35, 0.30, 1.0)))
+ assert e.value.code == ModelRejectCode.NOT_A_POSE
+ assert "Y-up" in e.value.message
+
+
+def test_pose_check_can_be_waived_but_facts_still_measured():
+ facts = check_model(make_glb((-0.12, 0.0, -0.05), (0.12, 1.0, 0.05)), check_pose=False)
+ assert facts.geometry_read
+ assert facts.arm_span_ratio == pytest.approx(0.24)
+
+
+def test_depth_counts_as_horizontal_span():
+ """横向判据取 ``max(X, Z)``:角色可能朝 X 也可能朝 Z,不能只看一条轴。
+ 一个"臂展窄但进深宽"的档不该按 0.24 判 —— 它量出来是 0.60。"""
+ facts = check_model(make_glb((-0.12, 0.0, -0.30), (0.12, 1.0, 0.30)))
+ assert facts.arm_span_ratio == pytest.approx(0.60)
+
+
+def test_node_scale_is_applied():
+ """节点上挂着非等比缩放时必须先变换再量 —— accessor 的 min/max 是**网格局部空间**的。
+ 不乘节点矩阵的话,一个被压扁到不成人形的档会顶着"看起来正常"的比例过检。"""
+ raw = make_glb((-0.35, 0.0, -0.05), (0.35, 1.0, 0.05))
+ squashed = make_glb((-0.35, 0.0, -0.05), (0.35, 1.0, 0.05), scale=(0.2, 1.0, 1.0))
+ assert read_glb_geometry(raw).arm_span_ratio == pytest.approx(0.70, abs=0.01)
+ assert read_glb_geometry(squashed).arm_span_ratio == pytest.approx(0.14, abs=0.01)
+ with pytest.raises(ModelRejected):
+ check_model(squashed)
+
+
+# ── 配件(弱信号) ──────────────────────────────────────────────────────────
+
+
+def test_weapon_named_mesh_is_rejected():
+ """实测:送入带剑的模型后,剑被错误绑上权重、动画里到处乱甩 —— 任务"成功"、积分照扣。"""
+ with pytest.raises(ModelRejected) as e:
+ check_model(make_glb(extra_mesh=("sword_01", (0.3, 0.4, -0.02), (0.36, 1.2, 0.02))))
+ assert e.value.code == ModelRejectCode.HAS_ACCESSORY
+ assert "sword_01" in e.value.message
+
+
+def test_weapon_token_matches_chinese_and_case():
+ for name in ("Blade_L", "长剑", "武器挂点"):
+ with pytest.raises(ModelRejected) as e:
+ check_model(make_glb(extra_mesh=(name, (0.3, 0.4, -0.02), (0.36, 1.2, 0.02))))
+ assert e.value.code == ModelRejectCode.HAS_ACCESSORY
+
+
+def test_extra_mesh_without_weapon_name_passes_but_is_reported():
+ """多一块网格**不**构成拒绝(头发/眼睛常单独成网格,误拒代价是挡掉合法调用),
+ 但要如实记进 facts,让人能自己判断。"""
+ facts = check_model(make_glb(extra_mesh=("hair", (-0.2, 0.8, -0.2), (0.2, 1.0, 0.2))))
+ assert facts.mesh_nodes == 2
+ assert "hair" in facts.named_parts
+
+
+def test_merged_weapon_is_not_detectable():
+ """**明确记下这一条测不了**:图生 3D 出的是单块网格,母版图里画了剑就和身体焊在
+ 同一个 mesh 里,没有独立节点也没有独立材质名 —— 本层一个信号都没有。
+ 这个用例存在的意义是:哪天有人以为预检守住了配件这条,能被它提醒。"""
+ facts = check_model(make_glb((-0.45, 0.0, -0.14), (0.45, 1.0, 0.14),
+ mesh_name="mesh", material_name="material"))
+ assert facts.mesh_nodes == 1
+ assert facts.named_parts == ("character", "mesh", "material")
+
+
+# ── 读不动的输入 ────────────────────────────────────────────────────────────
+
+
+def test_truncated_glb_is_rejected():
+ whole = make_glb()
+ with pytest.raises(ModelRejected) as e:
+ check_model(whole[: len(whole) // 2])
+ assert e.value.code == ModelRejectCode.UNREADABLE
+ assert "截断" in e.value.message
+
+
+def test_fbx_reports_geometry_not_read(rigged_fbx):
+ """FBX 没有白送的包围盒 —— 姿势/配件两条**测不了**。
+ 必须如实记成 geometry_read=False,不能冒充通过。"""
+ facts = check_model(rigged_fbx)
+ assert facts.fmt == "FBX"
+ assert facts.geometry_read is False
+ assert facts.arm_span_ratio is None
+ assert "未检" in facts.note()
+
+
+def test_glb_without_position_bounds_reports_not_read():
+ """有 GLB 结构但 accessor 没写 min/max(规范允许):同样标成没测,不当通过。"""
+ import json
+ import struct
+ doc = {"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}],
+ "nodes": [{"mesh": 0}], "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
+ "accessors": [{"type": "VEC3", "componentType": 5126, "count": 8}]}
+ payload = json.dumps(doc).encode()
+ payload += b" " * (-len(payload) % 4)
+ blob = struct.pack("<4sII", b"glTF", 2, 12 + 8 + len(payload))
+ blob += struct.pack(" str:
+ return "https://x/y"
+
+
+def test_implementations_satisfy_their_protocols():
+ assert isinstance(TencentModel3DProvider(CREDS), Model3DProvider)
+ assert isinstance(TencentAutoRigProvider(_Uploader(), CREDS), AutoRigProvider)
+ assert isinstance(LocalSpriteRenderProvider(), SpriteRenderProvider)
+ assert isinstance(TencentCosModelUploader(CREDS), ModelUploader)
+ assert isinstance(_Uploader(), ModelUploader)
+
+
+def test_signatures_match_the_protocol():
+ """签名逐个字对 —— Protocol 的 isinstance 查不出参数名/默认值变了。
+ 产品仓那边的调用点是按这些名字用关键字传的,改名就是破坏契约。"""
+ pairs = [
+ (Model3DProvider.image_to_3d, TencentModel3DProvider.image_to_3d),
+ (AutoRigProvider.rig, TencentAutoRigProvider.rig),
+ (SpriteRenderProvider.render, LocalSpriteRenderProvider.render),
+ (ModelUploader.upload, TencentCosModelUploader.upload),
+ ]
+ for proto, impl in pairs:
+ assert inspect.signature(proto) == inspect.signature(impl), proto.__qualname__
+
+
+def test_bytes_in_bytes_out_is_the_contract():
+ """三个 Protocol 的入参都是 bytes,不是 URL / 路径。
+ 照抄 VideoProvider 立的规矩:让每个调用点自己弄公网 URL 会把"对象存储"扩散到整条管线。
+ """
+ # 注意 ``from __future__ import annotations`` 让注解是字符串,不是类型对象 ——
+ # 拿 `is bytes` 比会永远为假,而消息还会打印成 "应当是 bytes,现在是 bytes"(踩过)。
+ sigs = {
+ "image_to_3d": inspect.signature(Model3DProvider.image_to_3d).parameters["master"],
+ "rig": inspect.signature(AutoRigProvider.rig).parameters["model"],
+ "render": inspect.signature(SpriteRenderProvider.render).parameters["rigged_model"],
+ }
+ for name, param in sigs.items():
+ assert param.annotation == "bytes", \
+ f"{name} 的主入参应当是 bytes,现在是 {param.annotation!r}"
+
+
+def test_autorig_requires_an_uploader_with_no_default():
+ """构造不出一个"没有上传能力的绑骨 provider" —— 免得跑到线上才发现模型送不出去
+ (那时任务已提交、积分已扣)。与 FalQueueVideoProvider 必须接 FirstFrameUploader 同构。"""
+ assert inspect.signature(TencentAutoRigProvider).parameters["uploader"].default \
+ is inspect.Parameter.empty
+ with pytest.raises(TypeError):
+ TencentAutoRigProvider() # type: ignore[call-arg]
+
+
+def test_rigged_model_rejects_a_bogus_format():
+ RiggedModel(data=b"glTF", fmt="GLB")
+ with pytest.raises(ArtifactFormatError):
+ RiggedModel(data=b"x", fmt="OBJ")
+
+
+def test_preset_motion_defaults_to_no_root_motion():
+ assert PresetMotion("walk", 23).has_root_motion is False
+
+
+def test_sprite_sheet_counts_frames_across_directions():
+ seqs = (SpriteSequence("e", 0.0, (b"1", b"2")), SpriteSequence("w", 180.0, (b"3",)))
+ sheet = SpriteSheet(clip="c", duration_s=1.0, sample_times=(0.0,), sequences=seqs,
+ rig=RigInfo(28, 1, 10, "root", "fbx"), available_clips={"c": 1.0})
+ assert sheet.frame_count == 3
+ assert len(seqs[0]) == 2
+
+
+def test_sequence_repr_does_not_dump_frame_bytes():
+ """一条序列是几十张 PNG。让它进 repr 会把日志和 pytest 的失败输出彻底冲掉。"""
+ text = repr(SpriteSequence("e", 0.0, (b"\x89PNG" + b"\x00" * 5000,)))
+ assert "frames" not in text and len(text) < 200
diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py
new file mode 100644
index 00000000..b0ed927b
--- /dev/null
+++ b/backend/tests/test_render3d_route_and_assets.py
@@ -0,0 +1,556 @@
+"""三渲二接进编排:谁选路线 + 造型级资产的成本性质。
+
+这一片锁的核心不是"能跑通",而是**三类静默错误**:
+
+ ① **路线永不可达。** 只要资产定位依赖一个 ``executor`` 从没赋过值的 card 字段,
+ 键就恒为 None、路线永远选不中,**而直接构造 card 的单元测试全绿**。所以本文件里
+ 最重要的是端到端那条(``test_real_server_path_reaches_render3d_*``):
+ 从"造型上有 model_3d_url"一路走到"渲帧策略真的被调用",中间不许有测试替身
+ 替代路线选择本身。
+
+ ② **静默回退。** 该走三渲二却悄悄出了一段 i2v,用户拿到画风 / 成本 / 多朝向能力
+ 完全不同的产物,而帧数、时长、成色全部正常,没有任何一道会红。
+
+ ③ **资产没被复用。** 图生 3D + 绑骨按动作重付,一个造型 10 个动作贵 10 倍。
+ 这条只会表现为"有点慢 + 账单变多",不会有任何报错(Refs #121)。
+"""
+from __future__ import annotations
+
+import io
+import pathlib
+
+import pytest
+from PIL import Image
+
+from windup_ai_engine.impl import CharacterGenerator
+from windup_ai_engine.strategy.concrete import RenderFrameStrategy, VideoFrameStrategy
+from windup_app.server.orchestrator.executor import ActionTaskExecutor, ProjectConstraints
+from windup_app.server.orchestrator.model import ActionType as InputActionType
+from windup_app.server.orchestrator.model import CharacterActionInput
+from windup_app.server.orchestrator.render3d_assets import (
+ LocalDirAssetStore,
+ LocalDirModelReview,
+ ModelAwaitingReview,
+ Render3DAssetBuilder,
+)
+from windup_common.models import (
+ ActionSpec,
+ ActionType,
+ CharacterCard,
+ Facing,
+ GenRoute,
+ Stylize,
+)
+try:
+ from windup_framework.providers.render3d import (
+ PresetMotion,
+ RiggedModel,
+ RigInfo,
+ SpriteSequence,
+ SpriteSheet,
+ )
+except ModuleNotFoundError as exc:
+ # 不静默通过:本文件的替身要**真** provider 的数据类型,自己糊一份等于测"我的假数据
+ # 长得像帧"。缺件时整体跳过并把缺的模块名报出来。
+ pytest.skip(
+ f"缺 {exc.name}(三渲二 provider 层,见 1024XEngineer/Windup#270);"
+ "该 PR 合入前本文件整体跳过。",
+ allow_module_level=True,
+ )
+
+
+def _png(w: int = 64, h: int = 96) -> bytes:
+ """一张带主体的真 RGBA PNG —— 假帧必须能被 _lastmile 真的解码/对齐,
+ 否则测的是"我的假数据长得像帧"而不是管线。"""
+ im = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ for y in range(20, 80):
+ for x in range(24, 40):
+ im.putpixel((x, y), (200, 60, 60, 255))
+ buf = io.BytesIO()
+ im.save(buf, "PNG")
+ return buf.getvalue()
+
+
+class _NullProgress:
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ pass
+
+
+class _SpyProgress:
+ def __init__(self) -> None:
+ self.notes: list[str] = []
+
+ def step(self, stage: str, i: int, total: int, note: str = "") -> None:
+ self.notes.append(note)
+
+
+# ── 假三段(记调用次数,用来证明"每造型一次性")──────────────────────────────
+
+
+class _FakeModel3D:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ def image_to_3d(self, master, *, want="GLB", extra_views=None) -> bytes:
+ self.calls += 1
+ return b"GLB-model-bytes"
+
+
+class _FakeAutoRig:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ @property
+ def preset_motions(self):
+ return {"walk": PresetMotion(name="walk", motion_type=1)}
+
+ def rig(self, model, *, want="GLB", motion=None) -> RiggedModel:
+ self.calls += 1
+ return RiggedModel(data=b"RIGGED-bytes", fmt="GLB")
+
+
+def _sheet(directions: tuple[str, ...], n_frames: int) -> SpriteSheet:
+ return SpriteSheet(
+ clip="walk",
+ duration_s=1.0,
+ sample_times=[i / n_frames for i in range(n_frames)],
+ sequences=[
+ SpriteSequence(direction=d, camera_yaw=0.0, frames=[_png()] * n_frames)
+ for d in directions
+ ],
+ rig=RigInfo(bones=28, skinned_meshes=1, vertices=100, root_bone="Hips", loader="glb"),
+ available_clips={"walk": 1.0},
+ )
+
+
+class _FakeRenderer:
+ """出帧台替身。**只有它是假的** —— 真出帧台要 node + playwright + three.js,
+ CI 里跑不了;而路线选择、策略装配、编排接线全部走真代码。"""
+
+ def __init__(self, directions=("e", "n", "w", "s")) -> None:
+ self.calls = 0
+ self.last_model: bytes | None = None
+ self._directions = directions
+ self.last_size: tuple[int, int] | None = None
+
+ def render(self, rigged_model, *, clip=None, directions=4, frames=12,
+ size=(1536, 2560), material="cel") -> SpriteSheet:
+ self.calls += 1
+ self.last_model = rigged_model
+ self.last_size = size
+ return _sheet(self._directions, frames)
+
+
+class _AutoApproveReview:
+ """测试替身:直接放行。**只用于不测这道闸的用例** —— 闸本身另有专门用例。"""
+
+ def __init__(self) -> None:
+ self.submitted: list[str] = []
+
+ def submit(self, key: str, model: bytes, fmt: str) -> str:
+ self.submitted.append(key)
+ return f"/{key}.{fmt.lower()}"
+
+ def is_approved(self, key: str) -> bool:
+ return True
+
+
+def _builder(tmp_path: pathlib.Path, may_build=True, review=None):
+ """``may_build`` 缺省 True:多数用例要验建资产那一支的行为,而这里的三段都是假的、
+ 不花真钱。**默认档(False)的行为另有专门用例**,见"花钱要有人点头"那一节。
+
+ ``review`` 缺省自动放行,同理 —— 人工确认停点的行为另有专门用例。"""
+ m, r = _FakeModel3D(), _FakeAutoRig()
+ return Render3DAssetBuilder(
+ model3d=m, autorig=r, store=LocalDirAssetStore(tmp_path),
+ review=review or _AutoApproveReview(),
+ may_build_assets=may_build,
+ ), m, r
+
+
+def _card() -> CharacterCard:
+ return CharacterCard(name="仙月", desc="美少女")
+
+
+def _spec(**kw) -> ActionSpec:
+ kw.setdefault("action", ActionType.WALK)
+ kw.setdefault("n_frames", 4)
+ kw.setdefault("stylize", Stylize.NONE)
+ return ActionSpec(**kw)
+
+
+OUTFIT = "outfit-hanfu-01"
+
+
+# ══ ① 端到端:走真实 server 路径能选到三渲二 ═══════════════════════════════
+#
+# 这一节是本文件存在的首要理由,别把它替身化。
+
+
+def _real_generator(renderer) -> CharacterGenerator:
+ """真的 CharacterGenerator + 真的 RenderFrameStrategy,只有出帧台是假的。"""
+ return CharacterGenerator({
+ GenRoute.VIDEO_I2V: VideoFrameStrategy(video=None, matte=None),
+ GenRoute.RENDER_3D: RenderFrameStrategy(renderer),
+ })
+
+
+def test_real_server_path_reaches_render3d_when_the_outfit_has_a_model():
+ """造型带 model_3d_url → 编排层真的走到渲帧策略,而不是 i2v。
+
+ 这条钉的正是"路线永不可达"那个缺陷:它只断言**编排层自己**选对了路线,
+ 没有任何替身代替这一步。
+ """
+ renderer = _FakeRenderer()
+ executor = ActionTaskExecutor(
+ generator=_real_generator(renderer),
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: pytest.fail("走三渲二不该去下载母版"),
+ fetch_model3d=lambda url: b"RIGGED-bytes",
+ fetch_constraints=lambda *_: ProjectConstraints(sprite_w=64, sprite_h=64),
+ )
+ out = executor._produce_action(
+ CharacterActionInput(
+ character_id=1,
+ action_type=InputActionType.WALK,
+ num_frames=4,
+ outfit_id=OUTFIT,
+ model_3d_url="https://cdn.example.com/outfits/hanfu.glb",
+ ),
+ ProjectConstraints(sprite_w=64, sprite_h=64),
+ )
+
+ assert renderer.calls == 1, "三渲二没被走到 —— 路线选择又断了"
+ assert renderer.last_model == b"RIGGED-bytes", "喂给出帧台的不是取回来的那个模型"
+ assert len(out["frames"]) == 4
+
+
+def test_real_server_path_stays_on_i2v_when_the_outfit_has_no_model():
+ """没有 model_3d_url 就照旧走 i2v —— 三渲二不是默认路线,也不该抢别人的活。"""
+ renderer = _FakeRenderer()
+ executor = ActionTaskExecutor(
+ generator=_real_generator(renderer),
+ upload=lambda _png: "https://cdn.example.com/f.png",
+ fetch_master=lambda _input: _png(),
+ fetch_model3d=lambda url: pytest.fail("没有 3D 资产却去取模型了"),
+ fetch_constraints=lambda *_: ProjectConstraints(sprite_w=64, sprite_h=64),
+ )
+ with pytest.raises(Exception):
+ # VideoFrameStrategy 的 provider 是 None,走到它必然炸 —— 这正是本用例要的:
+ # 证明它走的是 i2v 那一支。真正的 i2v 行为在别处有用例。
+ executor._produce_action(
+ CharacterActionInput(
+ character_id=1, action_type=InputActionType.WALK, num_frames=4,
+ outfit_id=OUTFIT, model_3d_url=None,
+ ),
+ ProjectConstraints(sprite_w=64, sprite_h=64),
+ )
+ assert renderer.calls == 0
+
+
+def test_web_layer_reads_the_outfit_model_url_into_the_task_input(auth_client, engine, monkeypatch):
+ """Web 层要把造型上的 model_3d_url **真的填进任务入参**。
+
+ 这一步是上次那个缺陷的落点:字段从没被赋过值,而下游全部正常运行、只是永远
+ 走不到三渲二。所以要断言的是"值到底进没进入参",不是"端点返回 200"。
+ """
+ from sqlalchemy.orm import sessionmaker
+
+ from conftest import seed_credit_account
+ from windup_app.web.api import generation as gen_api
+
+ # 提交动作生成会预冻结积分(#351),没有账户就在扣费那一步 404。
+ with sessionmaker(bind=engine)() as s:
+ seed_credit_account(s, 1)
+ s.commit()
+
+ project = auth_client.post("/projects", json={
+ "project_name": "三渲二", "character_perspective": 1, "directional_movement": 2,
+ "sprite_width": 64, "sprite_height": 64,
+ }).json()["data"]
+ character = auth_client.post("/characters", json={
+ "project_id": project["id"], "workflow_run_id": 1, "name": "勇者",
+ "character_data": {
+ "version": 1,
+ "outfits": [{
+ "id": OUTFIT, "name": "汉服",
+ "model_3d_url": "https://cdn.example.com/outfits/hanfu.glb",
+ "actions": [],
+ }],
+ },
+ }).json()["data"]
+
+ captured: list[CharacterActionInput] = []
+ monkeypatch.setattr(
+ gen_api, "_dispatch_after_commit",
+ # 位置参数随 _dispatch_after_commit 的签名走;这里只关心 input_data,
+ # 用 *args 收下其余,免得签名一变桩就报 TypeError。
+ lambda *args: captured.append(next(a for a in args if isinstance(a, CharacterActionInput))),
+ )
+ resp = auth_client.post("/generation/action", json={
+ "project_id": project["id"], "character_id": character["id"],
+ "action_type": "walk", "num_frames": 4, "outfit_id": OUTFIT,
+ })
+
+ assert resp.json()["data"] is not None, resp.json()
+ assert captured, "任务没被派发,拿不到入参"
+ assert captured[0].model_3d_url == "https://cdn.example.com/outfits/hanfu.glb"
+ assert captured[0].outfit_id == OUTFIT
+
+
+def test_web_layer_does_not_guess_an_outfit_when_none_is_given(auth_client, engine, monkeypatch):
+ """没给 outfit_id 就不许挑一个造型顶上 —— 猜错等于拿另一套衣服渲这次的动作,
+ 而帧数、时长、成色全部正常,没有任何一道会红。"""
+ from sqlalchemy.orm import sessionmaker
+
+ from conftest import seed_credit_account
+ from windup_app.web.api import generation as gen_api
+
+ # 提交动作生成会预冻结积分(#351),没有账户就在扣费那一步 404。
+ with sessionmaker(bind=engine)() as s:
+ seed_credit_account(s, 1)
+ s.commit()
+
+ project = auth_client.post("/projects", json={
+ "project_name": "三渲二", "character_perspective": 1, "directional_movement": 2,
+ "sprite_width": 64, "sprite_height": 64,
+ }).json()["data"]
+ character = auth_client.post("/characters", json={
+ "project_id": project["id"], "workflow_run_id": 1, "name": "勇者",
+ "character_data": {"version": 1, "outfits": [{
+ "id": OUTFIT, "name": "汉服",
+ "model_3d_url": "https://cdn.example.com/outfits/hanfu.glb", "actions": [],
+ }]},
+ }).json()["data"]
+
+ captured: list[CharacterActionInput] = []
+ monkeypatch.setattr(
+ gen_api, "_dispatch_after_commit",
+ # 位置参数随 _dispatch_after_commit 的签名走;这里只关心 input_data,
+ # 用 *args 收下其余,免得签名一变桩就报 TypeError。
+ lambda *args: captured.append(next(a for a in args if isinstance(a, CharacterActionInput))),
+ )
+ auth_client.post("/generation/action", json={
+ "project_id": project["id"], "character_id": character["id"],
+ "action_type": "walk", "num_frames": 4,
+ })
+
+ assert captured and captured[0].model_3d_url is None
+
+
+def test_unknown_outfit_id_is_rejected_not_ignored(auth_client):
+ """造型 id 对不上要报错。静默当成"没有资产"会让用户以为三渲二不可用,
+ 实际是他把 id 打错了。"""
+ project = auth_client.post("/projects", json={
+ "project_name": "三渲二", "character_perspective": 1, "directional_movement": 2,
+ "sprite_width": 64, "sprite_height": 64,
+ }).json()["data"]
+ character = auth_client.post("/characters", json={
+ "project_id": project["id"], "workflow_run_id": 1, "name": "勇者",
+ "character_data": {"version": 1, "outfits": []},
+ }).json()["data"]
+
+ resp = auth_client.post("/generation/action", json={
+ "project_id": project["id"], "character_id": character["id"],
+ "action_type": "walk", "num_frames": 4, "outfit_id": "不存在的造型",
+ })
+ assert resp.json()["code"] == 404
+
+
+# ══ ② 引擎侧:路线选择不归它管 ════════════════════════════════════════════
+
+
+def test_engine_has_no_route_field_to_be_told_which_route_to_take():
+ """``ActionSpec.route`` 已删(#122):路线选择整个移到 server,这个字段零消费方。
+
+ 留着一个"填了看起来会生效、实际没人读"的入参,正是本仓反复吃过的那类错。
+ """
+ with pytest.raises(Exception):
+ ActionSpec(action=ActionType.WALK, n_frames=4, route=GenRoute.RENDER_3D)
+
+
+def test_render3d_is_not_in_the_route_matrix():
+ """三渲二不进 ROUTE_MATRIX —— 那张表的前提是"路线由动作物理性质唯一决定",
+ 而走不走三渲二取决于造型有没有 3D 资产。塞进去就破了它的前提。"""
+ from windup_ai_engine.strategy.base import ROUTE_MATRIX
+
+ assert GenRoute.RENDER_3D not in ROUTE_MATRIX.values()
+
+
+def test_generate_rendered_raises_when_the_route_is_not_assembled():
+ """没装 RENDER_3D 的 strategy 就在边界上炸,不静默回退到 i2v。"""
+ gen = CharacterGenerator({GenRoute.VIDEO_I2V: VideoFrameStrategy(video=None, matte=None)})
+ with pytest.raises(NotImplementedError, match="render_3d"):
+ gen.generate_rendered(_card(), _spec(), b"RIGGED", _NullProgress())
+
+
+def test_empty_model_bytes_raise_before_the_render_stage():
+ """空模型必须在策略入口炸。放下去的话出帧台会报一句"Bad glTF",排查方向全跑偏。"""
+ renderer = _FakeRenderer()
+ with pytest.raises(ValueError, match="空的绑骨模型"):
+ RenderFrameStrategy(renderer).derive(_card(), _spec(), b"", _NullProgress())
+ assert renderer.calls == 0
+
+
+# ══ ③ 多朝向:如实上报,不闷掉 ════════════════════════════════════════════
+
+
+def test_requested_facing_picks_the_matching_direction():
+ renderer = _FakeRenderer(directions=("e", "n"))
+ spy = _SpyProgress()
+ RenderFrameStrategy(renderer).derive(
+ _card(), _spec(facing=Facing.FRONT), b"RIGGED", spy,
+ )
+ assert any("朝向 n" in n or "只带 n" in n for n in spy.notes), spy.notes
+
+
+def test_missing_direction_raises_instead_of_handing_back_another():
+ """出帧台没出请求的朝向就报错。换一个交出去 = 角色朝反方向走,而没有任何一道会红。"""
+ renderer = _FakeRenderer(directions=("w", "s"))
+ with pytest.raises(ValueError, match="没有产出朝向"):
+ RenderFrameStrategy(renderer).derive(_card(), _spec(), b"RIGGED", _NullProgress())
+
+
+def test_extra_directions_are_reported_not_silently_dropped():
+ """多渲出来的朝向零成本、但出参装不下 —— 这笔浪费要**可见**。"""
+ spy = _SpyProgress()
+ RenderFrameStrategy(_FakeRenderer(directions=("e", "n", "w", "s"))).derive(
+ _card(), _spec(), b"RIGGED", spy,
+ )
+ assert any("零成本可用但当前契约装不下" in n for n in spy.notes), spy.notes
+
+
+def test_render_uses_the_measured_portrait_canvas():
+ """出帧台画布用挣来的那个口径(竖图),不是随手一个方形。"""
+ from windup_framework.providers.render3d import RENDER_SIZE
+
+ renderer = _FakeRenderer()
+ RenderFrameStrategy(renderer).derive(_card(), _spec(), b"RIGGED", _NullProgress())
+ assert renderer.last_size == RENDER_SIZE
+
+
+def test_empty_render_output_raises():
+ class _EmptyRenderer(_FakeRenderer):
+ def render(self, rigged_model, **kw) -> SpriteSheet:
+ return SpriteSheet(
+ clip="walk", duration_s=1.0, sample_times=[],
+ sequences=[SpriteSequence(direction="e", camera_yaw=0.0, frames=[])],
+ rig=RigInfo(bones=1, skinned_meshes=1, vertices=1, root_bone="Hips",
+ loader="glb"),
+ available_clips={"walk": 1.0},
+ )
+
+ with pytest.raises(ValueError, match="未产出任何帧"):
+ RenderFrameStrategy(_EmptyRenderer()).derive(
+ _card(), _spec(), b"RIGGED", _NullProgress(),
+ )
+
+
+# ══ ④ 成本性质:造型级资产每造型一次性 ════════════════════════════════════
+
+
+def test_second_action_reuses_assets_and_pays_nothing_extra(tmp_path):
+ builder, m, r = _builder(tmp_path)
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ assert (m.calls, r.calls) == (1, 1), "图生 3D / 绑骨按动作重付了"
+
+
+def test_assets_survive_a_new_builder_instance(tmp_path):
+ """落点必须跨进程持久 —— 进程内缓存等于每次重启都重付一遍。"""
+ b1, m1, r1 = _builder(tmp_path)
+ b1.ensure(OUTFIT, _png(), _NullProgress())
+ b2, m2, r2 = _builder(tmp_path)
+ b2.ensure(OUTFIT, _png(), _NullProgress())
+ assert (m2.calls, r2.calls) == (0, 0)
+
+
+def test_different_outfits_do_not_share_a_model(tmp_path):
+ """不同造型是不同外观,共用一个 3D 模型 = 拿错衣服渲,而没有任何一道会红。"""
+ builder, m, r = _builder(tmp_path)
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ builder.ensure("outfit-armor-02", _png(), _NullProgress())
+ assert (m.calls, r.calls) == (2, 2)
+
+
+def test_missing_outfit_key_raises_before_spending(tmp_path):
+ builder, m, r = _builder(tmp_path)
+ with pytest.raises(ValueError, match="缺少造型 id"):
+ builder.ensure("", _png(), _NullProgress())
+ assert (m.calls, r.calls) == (0, 0)
+
+
+def test_get_costs_nothing(tmp_path):
+ """``get`` 是 server 选路线时调的,必须不花钱、无副作用。"""
+ builder, m, r = _builder(tmp_path)
+ assert builder.get(OUTFIT) is None
+ assert (m.calls, r.calls) == (0, 0)
+
+
+# ══ ⑤ 花钱要有人点头 ═════════════════════════════════════════════════════
+
+
+def test_default_posture_refuses_to_build_assets(tmp_path):
+ """默认不授权花钱:一个 web 请求不该顺手触发按次计费。"""
+ builder, m, r = _builder(tmp_path, may_build=False)
+ with pytest.raises(ValueError, match="未获准建"):
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ assert (m.calls, r.calls) == (0, 0)
+
+
+def test_default_posture_still_serves_outfits_that_already_have_assets(tmp_path):
+ """已有资产的造型照常出帧 —— 默认档挡的是"建",不是"用"。"""
+ b1, _, _ = _builder(tmp_path, may_build=True)
+ b1.ensure(OUTFIT, _png(), _NullProgress())
+ b2, m2, r2 = _builder(tmp_path, may_build=False)
+ assert b2.ensure(OUTFIT, _png(), _NullProgress()) == b"RIGGED-bytes"
+ assert (m2.calls, r2.calls) == (0, 0)
+
+
+# ══ ⑥ 生成的 3D 模型必须先给人看过才往下走 ═══════════════════════════════
+
+
+class _NeverApprove(_AutoApproveReview):
+ def is_approved(self, key: str) -> bool:
+ return False
+
+
+def test_model_awaits_review_before_paying_for_rigging(tmp_path):
+ """停点在图生 3D 之后、绑骨之前:信息最全而花费最少的位置。"""
+ builder, m, r = _builder(tmp_path, review=_NeverApprove())
+ with pytest.raises(ModelAwaitingReview):
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ assert (m.calls, r.calls) == (1, 0), "还没人点头就把绑骨的钱花了"
+
+
+def test_waiting_for_review_does_not_repay_image_to_3d(tmp_path):
+ """待审期间反复调用不该重付图生 3D —— 停点的本意恰恰是省钱。"""
+ builder, m, _ = _builder(tmp_path, review=_NeverApprove())
+ for _ in range(3):
+ with pytest.raises(ModelAwaitingReview):
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ assert m.calls == 1
+
+
+def test_review_never_self_approves(tmp_path):
+ """放行只能靠人手动建标记文件。超时自动放行的闸等于没有闸。"""
+ gate = LocalDirModelReview(tmp_path / "review")
+ gate.submit(OUTFIT, b"GLB", "GLB")
+ assert gate.is_approved(OUTFIT) is False
+ gate.approve(OUTFIT)
+ assert gate.is_approved(OUTFIT) is True
+
+
+def test_after_approval_it_proceeds_and_reuses_the_stored_model(tmp_path):
+ """人点头之后继续绑骨,且**不重付**图生 3D(待审期间那份已经存下来了)。"""
+ store = LocalDirAssetStore(tmp_path)
+ gate = LocalDirModelReview(tmp_path / "review")
+ m, r = _FakeModel3D(), _FakeAutoRig()
+ builder = Render3DAssetBuilder(
+ model3d=m, autorig=r, store=store, review=gate, may_build_assets=True,
+ )
+ with pytest.raises(ModelAwaitingReview):
+ builder.ensure(OUTFIT, _png(), _NullProgress())
+ gate.approve(OUTFIT)
+ assert builder.ensure(OUTFIT, _png(), _NullProgress()) == b"RIGGED-bytes"
+ assert (m.calls, r.calls) == (1, 1)
diff --git a/backend/tests/test_render3d_sprite.py b/backend/tests/test_render3d_sprite.py
new file mode 100644
index 00000000..e0b976d8
--- /dev/null
+++ b/backend/tests/test_render3d_sprite.py
@@ -0,0 +1,410 @@
+"""出帧 provider 的用例。
+
+这一段**零 API 成本**,所以真出帧的用例可以放心跑(标 ``slow``,一条数秒)。
+输入用本仓已有的绑骨产物 ``characters/oc_v4/rigged_despill.fbx`` —— 那笔钱早花过了。
+"""
+from __future__ import annotations
+
+import pathlib
+
+import numpy as np
+import pytest
+from PIL import Image
+import io
+
+from render3d_helpers import make_glb
+
+from windup_framework.providers.render3d import (
+ DIRECTIONS_4,
+ DIRECTIONS_8,
+ MATERIALS,
+ LocalSpriteRenderProvider,
+ ModelRejected,
+ RenderStageError,
+)
+
+
+# ── 边界校验(不起浏览器) ────────────────────────────────────────────────────
+
+
+def test_unknown_material_is_refused():
+ """**仪器陷阱**:管线那份出帧台的材质分支只认三种取值,其余(含 cel / studio)静默落到
+ 同一个分支 —— 于是"拿两种材质做对照"实际根本没换材质,结论不作数。
+ provider 在边界上校验,不给静默兜底留口子。"""
+ p = LocalSpriteRenderProvider()
+ with pytest.raises(ValueError, match="未知材质"):
+ p.render(b"glTF\x00", material="studio")
+ for good in MATERIALS:
+ assert good in MATERIALS
+
+
+def test_material_table_has_no_silent_aliases():
+ """表里每个取值都必须对应出帧台里一个**显式分支**,而且出帧台没有兜底分支。
+
+ 第一版这条用例只 grep ``'toon'`` 在不在文件里 —— 结果把 toon 分支整行删掉它还是绿的
+ (因为 MATERIALS 数组里也有 'toon' 这个字面量)。变异测试逮到的,现在改成查分支条件。
+ """
+ from windup_framework.providers.render3d.sprite import STAGE_DIR
+ stage = (STAGE_DIR / "bake_stage.html").read_text()
+ for m in MATERIALS:
+ if m == "orig":
+ assert "MAT !== 'orig'" in stage, "orig 应当整段跳过材质替换"
+ continue
+ assert f"MAT === '{m}'" in stage, f"出帧台里没有材质 {m} 的显式分支"
+ assert "别静默兜底" in stage, "出帧台的材质三元表必须以抛错收尾,不能有 fall-through"
+ # 白名单闸是**双保险**:provider 那道拦的是走 provider 的调用方,这道拦的是直接开页面的人
+ # (调参数、做对照实验时最常这么干,也正是仪器陷阱当初发生的场合)。
+ assert "MATERIALS.includes(MAT)" in stage, "出帧台自己也要拒认不出的材质,不能只靠 provider"
+
+
+def test_framing_measures_bone_positions_not_mesh_bounds():
+ """构图必须量**骨骼世界位置**,不能用 ``Box3.setFromObject`` 量 SkinnedMesh。
+
+ 机制:蒙皮变形在 GPU 上做,CPU 侧的几何顶点**从来不动**,所以 ``setFromObject``
+ 量出来永远是绑定姿态的盒子(症状:含跳跃在内的五个动作量出来高度全一样,跳跃腾空时
+ 头切出画面)。
+
+ **这是一条白盒(结构)用例,不是行为用例 —— 为什么只能这样**:2026-08-11 实测,
+ 把实现换成 ``setFromObject`` 后,本仓唯一可用的绑骨产物(单条 walk 片段)渲出来
+ 占高只从 0.716–0.722 变成 0.728–0.738,差 1.5% 帧高。要用行为用例杀掉它就得把
+ 占高卡在 0.71–0.73 这么窄的窗里,那是对**这一个角色**过拟合,换个角色就误报。
+ 这个陷阱真正发威要靠大幅度动作(跳跃)的片段,而我们手上没有 —— 所以退一步锁机制,
+ 并把"没有行为用例覆盖"这件事写在这里,别让人以为已经守住了。
+ """
+ from windup_framework.providers.render3d.sprite import STAGE_DIR
+ stage = (STAGE_DIR / "bake_stage.html").read_text()
+ assert "setFromMatrixPosition(b.matrixWorld)" in stage
+ assert "expandByScalar(0.10)" in stage, "骨骼是线、网格有厚度,余量不能省"
+
+
+def test_direction_count_is_4_or_8():
+ p = LocalSpriteRenderProvider()
+ for bad in (1, 2, 6, 16):
+ with pytest.raises(ValueError, match="4 或 8"):
+ p.render(b"glTF\x00", directions=bad)
+
+
+def test_eight_directions_are_a_superset_of_four():
+ assert set(DIRECTIONS_4) < set(DIRECTIONS_8)
+ for k, v in DIRECTIONS_4.items():
+ assert DIRECTIONS_8[k] == v # 同名朝向的方位角必须一致,否则 4/8 向对不齐
+
+
+def test_direction_yaws_are_45_degree_steps():
+ assert sorted(DIRECTIONS_8.values()) == [0, 45, 90, 135, 180, 225, 270, 315]
+
+
+def test_frames_must_be_positive():
+ with pytest.raises(ValueError, match="帧数"):
+ LocalSpriteRenderProvider().render(b"glTF\x00", frames=0)
+
+
+def test_garbage_model_is_refused_by_sniffing():
+ with pytest.raises(ModelRejected):
+ LocalSpriteRenderProvider().render(b"not a model at all")
+
+
+def test_missing_three_is_a_readable_error(rigged_fbx):
+ p = LocalSpriteRenderProvider(three_dir="/nonexistent/three")
+ p._three = None
+ with pytest.raises(RenderStageError, match="three.js"):
+ p.render(rigged_fbx)
+
+
+# ── 真出帧 ──────────────────────────────────────────────────────────────────
+
+
+@pytest.fixture(scope="module")
+def sheet(rigged_fbx):
+ p = LocalSpriteRenderProvider()
+ if p._three is None:
+ pytest.skip("本机找不到 three.js(设 WINDUP_THREE_DIR)")
+ return p.render(rigged_fbx, directions=4, frames=8, size=(512, 640), material="cel")
+
+
+@pytest.mark.slow
+def test_render_reports_the_established_rig_facts(sheet):
+ """已确立的事实:自动绑骨产出 **28 骨**、humanoid 命名、**无 mixamorig: 前缀**。
+ 对不上说明拿到的不是这条链路的产物,该停下来看而不是接着渲。"""
+ assert sheet.rig.bones == 28
+ assert sheet.rig.skinned_meshes >= 1
+ assert sheet.rig.loader == "fbx"
+ assert sheet.rig.root_bone == "root"
+
+
+@pytest.mark.slow
+def test_preset_motion_has_zero_root_displacement(sheet):
+ """48 个预设全部零根位移 —— 这里量的是出帧台从位置轨抽出来的实际位移。
+ 它不为 0 就说明拿到的不是绑骨预设动作(或抽取逻辑坏了)。"""
+ assert sheet.root_motion is not None
+ assert sheet.root_motion["total_span"] == 0
+
+
+@pytest.mark.slow
+def test_all_four_directions_come_out(sheet):
+ assert [s.direction for s in sheet.sequences] == list(DIRECTIONS_4)
+ assert [s.camera_yaw for s in sheet.sequences] == [0, 90, 180, 270]
+ assert sheet.frame_count == 32
+ for s in sheet.sequences:
+ assert len(s.frames) == 8
+ assert all(f[:8] == b"\x89PNG\r\n\x1a\n" for f in s.frames) # 真 PNG,不是空串
+
+
+@pytest.mark.slow
+def test_frames_are_not_blank(sheet):
+ """空帧自检的正面:每帧都得有实体像素。出帧台会在角色出画/片段选错时静默产出
+ 全透明帧,而外层照样打印"N 帧 时长…" —— 那次三帧 alpha 全 0,零告警。"""
+ for s in sheet.sequences:
+ for i, raw in enumerate(s.frames):
+ alpha = np.asarray(Image.open(io.BytesIO(raw)).convert("RGBA"))[..., 3]
+ solid = (alpha > 128).mean()
+ assert 0.02 < solid < 0.6, f"{s.direction}/f{i} 实体占比 {solid:.4f} 不像个角色"
+
+
+@pytest.mark.slow
+def test_camera_yaw_actually_changes_the_image(sheet):
+ """**先验仪器**:如果 camyaw 没生效,四个朝向会是同一张图,而"多朝向已跑通"就是假的。
+ e(0°) 与 w(180°) 是对侧视角,应当接近**水平镜像**但不相等(角色左右不对称)。"""
+ by = {s.direction: s for s in sheet.sequences}
+ e = np.asarray(Image.open(io.BytesIO(by["e"].frames[0])).convert("RGBA")).astype(int)
+ w = np.asarray(Image.open(io.BytesIO(by["w"].frames[0])).convert("RGBA")).astype(int)
+ n = np.asarray(Image.open(io.BytesIO(by["n"].frames[0])).convert("RGBA")).astype(int)
+ assert not np.array_equal(e, w) and not np.array_equal(e, n)
+ mirrored = np.abs(e - w[:, ::-1, :]).mean()
+ direct = np.abs(e - w).mean()
+ assert mirrored < direct / 2, f"e 与 w 不成镜像关系(镜像差 {mirrored:.2f} vs 直接差 {direct:.2f})"
+
+
+@pytest.mark.slow
+def test_legs_alternate_across_the_walk_cycle(sheet):
+ """步态判据看**腿有没有左右交替**,不看对齐指标 —— 逐帧图生图路线量不出这个差别
+ (它出来的是踢踏舞:腿在动但不交替)。侧视剪影宽度在一个走路周期里应当出现两个峰。"""
+ by = {s.direction: s for s in sheet.sequences}
+ widths = []
+ for raw in by["e"].frames:
+ a = np.asarray(Image.open(io.BytesIO(raw)).convert("RGBA"))[..., 3] > 128
+ xs = np.where(a.any(axis=0))[0]
+ widths.append(int(xs.max() - xs.min()))
+ peaks = [i for i in range(len(widths))
+ if widths[i] > widths[i - 1] and widths[i] > widths[(i + 1) % len(widths)]]
+ assert len(peaks) == 2, f"侧视剪影宽度 {widths} 只有 {len(peaks)} 个峰,不像左右交替的步态"
+ assert max(widths) - min(widths) > 0.3 * max(widths), f"跨度变化太小:{widths}"
+
+
+@pytest.mark.slow
+def test_framing_is_fixed_across_directions(sheet):
+ """构图一次算定、跨朝向固定。各朝向的脚线(剪影底边)必须落在同一行 ——
+ 对不齐的话拼进同一张精灵表就会上下跳。"""
+ bottoms = []
+ for s in sheet.sequences:
+ a = np.asarray(Image.open(io.BytesIO(s.frames[0])).convert("RGBA"))[..., 3] > 128
+ rows, cols = np.where(a.any(axis=1))[0], np.where(a.any(axis=0))[0]
+ bottoms.append(int(rows.max()))
+ # 不贴边:贴到画幅边缘就说明取景把角色切了(跳跃切头就是这么发生的)。
+ assert rows.min() > 0 and rows.max() < a.shape[0] - 1, f"{s.direction} 纵向被切"
+ assert cols.min() > 0 and cols.max() < a.shape[1] - 1, f"{s.direction} 横向被切"
+ assert max(bottoms) - min(bottoms) <= 4, f"各朝向脚线不齐:{bottoms}"
+
+
+@pytest.mark.slow
+def test_sampling_is_deterministic(rigged_fbx):
+ """确定性取样(mixer.setTime,不靠实时播放):同一入参跑两次必须逐位一致。
+ 不一致就说明取样受实时时钟影响,那么任何"改了参数导致变化"的结论都不作数。"""
+ p = LocalSpriteRenderProvider()
+ if p._three is None:
+ pytest.skip("本机找不到 three.js")
+ kw = dict(directions=4, frames=2, size=(256, 320), material="cel")
+ a = p.render(rigged_fbx, **kw)
+ b = p.render(rigged_fbx, **kw)
+ assert a.sample_times == b.sample_times
+ for sa, sb in zip(a.sequences, b.sequences):
+ assert tuple(sa.frames) == tuple(sb.frames), f"{sa.direction} 两次跑不一致"
+
+
+@pytest.mark.slow
+def test_blank_frame_gate_actually_fires(rigged_fbx):
+ """把空帧阈值调到 0.999(没有哪帧能达到)→ 必须报错。
+
+ 这条用例验的是**闸门本身**,不是模型:一个从不触发的自检等于没有自检,
+ 而"空白冒充成功"正是这条线踩过的坑。
+ """
+ p = LocalSpriteRenderProvider(min_coverage=0.999)
+ if p._three is None:
+ pytest.skip("本机找不到 three.js")
+ # 锚到 provider 自己那句,而不是 driver 打在 stderr 里的那句:只匹配 "空帧自检" 的话,
+ # 把 returncode==2 那个分支整段删掉用例还是绿的(错误照样抛,只是变成一句
+ # "出帧失败(退出码 2)" + 原文 stderr)—— 变异测试逮到的,诊断质量得单独锁住。
+ with pytest.raises(RenderStageError, match="出帧台空帧自检不通过"):
+ p.render(rigged_fbx, directions=4, frames=1, size=(256, 320))
+
+
+@pytest.mark.slow
+def test_unknown_clip_name_lists_what_exists(rigged_fbx):
+ p = LocalSpriteRenderProvider()
+ if p._three is None:
+ pytest.skip("本机找不到 three.js")
+ with pytest.raises(RenderStageError, match="没有片段"):
+ p.render(rigged_fbx, clip="Walking", directions=4, frames=1, size=(256, 320))
+
+
+# ── 依赖发现:纯本地路径逻辑,不需要真的出帧 ─────────────────────────────────
+
+
+def test_three_discovery_survives_unstat_able_paths(monkeypatch, tmp_path):
+ """向上搜 node_modules 会走到 /,那里有些合成入口对 stat 直接报错。
+
+ 问不出来必须当成"没有",否则整条发现逻辑连同所有依赖它的用例一起崩。
+ """
+ from windup_framework.providers.render3d import sprite
+
+ class _Boom(pathlib.Path):
+ def is_dir(self):
+ raise OSError(22, "Invalid argument")
+
+ monkeypatch.delenv("WINDUP_THREE_DIR", raising=False)
+ assert sprite._is_dir(_Boom(tmp_path)) is False
+
+
+def test_explicit_three_dir_wins_over_search(monkeypatch, tmp_path):
+ """显式指定就不再搜 —— 搜索是兜底,不该覆盖调用方的明确意图。"""
+ from windup_framework.providers.render3d import sprite
+
+ monkeypatch.setenv("WINDUP_THREE_DIR", str(tmp_path / "my-three"))
+ assert sprite._discover_three() == tmp_path / "my-three"
+
+
+def test_missing_three_reports_how_to_fix():
+ """找不到 three 要说清怎么办,不能只抛一句 None。"""
+ from windup_framework.providers.render3d import (
+ LocalSpriteRenderProvider,
+ RenderStageError,
+ )
+
+ p = LocalSpriteRenderProvider(three_dir=None)
+ p._three = None
+ with pytest.raises(RenderStageError, match="three"):
+ p.render(make_glb(), directions=4, frames=4)
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "match"),
+ [
+ ({"directions": 3}, "朝向数"),
+ ({"directions": 0}, "朝向数"),
+ ({"material": "studio"}, "未知材质"),
+ ({"frames": 0}, "帧数"),
+ ],
+)
+def test_bad_arguments_are_refused_before_any_work(kwargs, match):
+ """认不出的取值当场抛。
+
+ 材质那条尤其重要:有兜底分支的话拼错的名字会静默落到同一处,"换材质做对照"
+ 实际没换,而据此得出的结论不作数。
+ """
+ from windup_framework.providers.render3d import LocalSpriteRenderProvider
+
+ call = {"directions": 4, "frames": 4, "material": "cel"}
+ call.update(kwargs)
+ with pytest.raises(ValueError, match=match):
+ LocalSpriteRenderProvider().render(make_glb(), **call)
+
+
+# ── 出帧台失败路径(不起浏览器:替掉 subprocess.run) ──────────────────────────
+#
+# 这些分支的共同点是**出帧已经跑过、但产物不可信**。它们必须抛错而不是往下走:
+# 全透明帧 / 缺 meta 都能凑出一份"帧数对、无异常"的产物,而那正是本条线踩过的陷阱。
+
+
+def _provider(tmp_path):
+ """绕开 three.js 发现:这些用例测的是 node 子进程的返回,与 three 在不在无关。"""
+ (tmp_path / "three").mkdir(exist_ok=True)
+ return LocalSpriteRenderProvider(three_dir=tmp_path / "three")
+
+
+class _Proc:
+ def __init__(self, returncode=0, stdout="", stderr=""):
+ self.returncode, self.stdout, self.stderr = returncode, stdout, stderr
+
+
+def _run_render(monkeypatch, tmp_path, fake_run):
+ from windup_framework.providers.render3d import sprite as _sprite
+ monkeypatch.setattr(_sprite.subprocess, "run", fake_run)
+ return _provider(tmp_path).render(make_glb(), directions=4, frames=2)
+
+
+def test_missing_node_says_which_binary_failed(monkeypatch, tmp_path):
+ def _boom(*a, **k):
+ raise FileNotFoundError("node")
+ with pytest.raises(RenderStageError, match="起不来 node"):
+ _run_render(monkeypatch, tmp_path, _boom)
+
+
+def test_render_timeout_reports_the_budget(monkeypatch, tmp_path):
+ import subprocess as _sp
+
+ def _slow(*a, **k):
+ raise _sp.TimeoutExpired(cmd="node", timeout=900)
+ with pytest.raises(RenderStageError, match="出帧超时"):
+ _run_render(monkeypatch, tmp_path, _slow)
+
+
+def test_blank_frame_selftest_is_not_swallowed(monkeypatch, tmp_path):
+ """退出码 2 = 出帧台自己判定全透明。**不能当成功**——三帧 alpha 全 0 时
+ 外层照样能打印"N 帧 时长…",没有任何告警,排查方向整个跑偏。"""
+ def _blank(*a, **k):
+ return _Proc(returncode=2, stderr="coverage 0.000 < 0.005")
+ with pytest.raises(RenderStageError, match="空帧自检"):
+ _run_render(monkeypatch, tmp_path, _blank)
+
+
+def test_generic_failure_surfaces_the_exit_code_and_output(monkeypatch, tmp_path):
+ def _fail(*a, **k):
+ return _Proc(returncode=7, stderr="WebGL context lost")
+ with pytest.raises(RenderStageError, match="退出码 7"):
+ _run_render(monkeypatch, tmp_path, _fail)
+
+
+def test_failure_without_stderr_falls_back_to_stdout(monkeypatch, tmp_path):
+ """错误信息两头都可能空;两头都不看的话报错就成了一句"失败了"。"""
+ def _fail(*a, **k):
+ return _Proc(returncode=1, stderr="", stdout="loader threw")
+ with pytest.raises(RenderStageError, match="loader threw"):
+ _run_render(monkeypatch, tmp_path, _fail)
+
+
+def test_success_without_meta_file_is_still_a_failure(monkeypatch, tmp_path):
+ """退出码 0 但没写 bake_meta.json —— "没崩" 不等于 "出了帧"。"""
+ def _ok(*a, **k):
+ return _Proc(returncode=0, stdout="done")
+ with pytest.raises(RenderStageError, match="bake_meta.json"):
+ _run_render(monkeypatch, tmp_path, _ok)
+
+
+def test_a_direction_with_no_frames_names_the_direction(monkeypatch, tmp_path):
+ """meta 写了、帧目录空。报出**是哪个朝向**缺帧,否则只能去翻临时目录。"""
+ import json as _json
+
+ def _ok_but_empty(*a, **k):
+ out = pathlib.Path(k["env"]["OUT"])
+ (out / "bake_meta.json").write_text(_json.dumps({"clip": "idle"}))
+ return _Proc(returncode=0)
+ with pytest.raises(RenderStageError, match="一帧都没出"):
+ _run_render(monkeypatch, tmp_path, _ok_but_empty)
+
+
+def test_driver_uses_bundled_chromium_not_branded_chrome():
+ """出帧台不得要求品牌版 Chrome。
+
+ ``channel: 'chrome'`` 在装了 Chrome 的开发机上能跑,在容器镜像里起不来
+ (playwright 只带 chromium),于是整个渲帧段无法部署。也不接受"先试 chrome
+ 再退回 chromium"的链:出帧结果依赖具体浏览器,静默换一个等于同一份模型在
+ 不同机器上出不同的帧。
+ """
+ from windup_framework.providers.render3d.sprite import STAGE_DIR
+
+ # 只看代码行:注释里提到 channel 是在解释为什么不用它。
+ code = [ln for ln in (STAGE_DIR / "bake_driver.mjs").read_text(encoding="utf-8").splitlines()
+ if not ln.lstrip().startswith(("//", "*", "/*"))]
+ launch = [ln for ln in code if "chromium.launch" in ln]
+ assert launch, "找不到浏览器启动这一行,断言会空跑成绿的"
+ assert "channel" not in "".join(launch), f"指定了浏览器 channel,容器里会起不来:{launch}"
diff --git a/backend/tests/test_render3d_tencent.py b/backend/tests/test_render3d_tencent.py
new file mode 100644
index 00000000..060c4629
--- /dev/null
+++ b/backend/tests/test_render3d_tencent.py
@@ -0,0 +1,735 @@
+"""两个计费 provider 的用例 —— **一次真任务都不提交**。
+
+提交路径靠替掉 :func:`windup_framework.providers.render3d.tencent.call`(唯一的出网口)来验形状:
+参数怎么组、状态机怎么走、产物怎么挑、错误怎么分类。真提交的钱早花过了,产物在
+``characters/oc_v4/`` 躺着,重跑一遍只是重复付钱。
+"""
+from __future__ import annotations
+
+import io
+
+import pytest
+
+from windup_framework.providers.render3d import (
+ ArtifactFormatError,
+ InsufficientCreditsError,
+ JobFailedError,
+ JobTimeoutError,
+ ModelNotPublicError,
+ ModelRejected,
+ PresetMotion,
+ RiggedModel,
+ SpendNotAuthorizedError,
+ TencentAutoRigProvider,
+ TencentCredentials,
+ TencentModel3DProvider,
+ redact,
+)
+from windup_framework.providers.render3d import tencent as T
+
+from render3d_helpers import make_glb
+
+CREDS = TencentCredentials("AKIDtestonly", "sk-testonly", "ap-guangzhou")
+GLB = b"glTF" + b"\x00" * 200
+FBX = b"Kaydara FBX Binary \x00" + b"\x00" * 200
+
+
+class FakeCloud:
+ """替掉 ``tencent.call`` 的假云。``script`` 是 action → 依次返回的响应列表。"""
+
+ def __init__(self, **script: list[dict]) -> None:
+ self.script = {k: list(v) for k, v in script.items()}
+ self.calls: list[tuple[str, dict]] = []
+
+ def __call__(self, action, params, *, service, version, creds, **kw):
+ self.calls.append((action, params))
+ queue = self.script.get(action)
+ if not queue:
+ raise AssertionError(f"假云没有为 {action} 准备响应(已调 {len(self.calls)} 次)")
+ return queue.pop(0) if len(queue) > 1 else queue[0]
+
+ def params_for(self, action: str) -> dict:
+ return next(p for a, p in self.calls if a == action)
+
+ def count(self, action: str) -> int:
+ return sum(1 for a, _ in self.calls if a == action)
+
+
+class Uploader:
+ """记账用的假 uploader。默认返回一个像样的公网 URL。"""
+
+ def __init__(self, url: str = "https://bucket.cos.example.com/abc.glb?sig=x") -> None:
+ self.url = url
+ self.seen: list[tuple[int, str]] = []
+
+ def upload(self, model: bytes, content_type: str) -> str:
+ self.seen.append((len(model), content_type))
+ return self.url
+
+
+@pytest.fixture
+def cloud(monkeypatch):
+ def install(**script):
+ fake = FakeCloud(**script)
+ monkeypatch.setattr(T, "call", fake)
+ return fake
+ return install
+
+
+# 在 no_real_download 打桩之前抓住真函数。模块级赋值在导入期执行,早于任何 fixture,
+# 所以这是唯一能拿到未打桩实现的时机 —— 直接引用 T._download 拿到的是那个假实现。
+_REAL_DOWNLOAD = T._download
+
+
+@pytest.fixture(autouse=True)
+def no_real_download(monkeypatch):
+ """产物下载也堵死:任何用例真去 GET 一个 URL 都算测试写漏了。"""
+ monkeypatch.setattr(T, "_download", lambda url, **kw: _DOWNLOADS.get(url, GLB))
+ yield
+
+
+_DOWNLOADS: dict[str, bytes] = {}
+
+
+@pytest.fixture(autouse=True)
+def clean_downloads():
+ _DOWNLOADS.clear()
+ yield
+ _DOWNLOADS.clear()
+
+
+@pytest.fixture(autouse=True)
+def no_sleep(monkeypatch):
+ monkeypatch.setattr(T.time, "sleep", lambda s: None)
+
+
+# ── 花钱这道闸 ──────────────────────────────────────────────────────────────
+
+
+def test_model3d_refuses_to_spend_by_default(cloud):
+ fake = cloud()
+ p = TencentModel3DProvider(CREDS)
+ with pytest.raises(SpendNotAuthorizedError) as e:
+ p.image_to_3d(b"\x89PNG fake")
+ assert "20 积分" in str(e.value) and "¥2.4" in str(e.value) # 报价必须写在异常里
+ assert fake.calls == [] # 一个请求都没发出去
+
+
+def test_autorig_refuses_to_spend_by_default(cloud):
+ fake = cloud()
+ up = Uploader()
+ p = TencentAutoRigProvider(up, CREDS)
+ with pytest.raises(SpendNotAuthorizedError) as e:
+ p.rig(make_glb())
+ assert "10 积分" in str(e.value)
+ assert fake.calls == [] and up.seen == [] # 也没上传 —— 上传本身要占带宽
+
+
+def test_precheck_runs_before_the_spend_gate(cloud):
+ """超限的档报的是"体积超限"而不是"没授权花钱":一个注定产出错结果的输入,
+ 连报价都不该走到。"""
+ cloud()
+ p = TencentAutoRigProvider(Uploader(), CREDS)
+ with pytest.raises(ModelRejected):
+ p.rig(make_glb(pad=61 * 10**6))
+
+
+def test_unknown_motion_name_dies_before_the_spend_gate(cloud):
+ cloud()
+ p = TencentAutoRigProvider(Uploader(), CREDS)
+ with pytest.raises(KeyError):
+ p.rig(make_glb(), motion="backflip")
+
+
+def test_quotes_are_pure_computation():
+ assert TencentModel3DProvider(CREDS).quote() == (20, 2.4)
+ assert TencentModel3DProvider(CREDS, generate_type="Geometry").quote() == (15, 1.8)
+ assert TencentModel3DProvider(CREDS, enable_pbr=True).quote() == (30, 3.6)
+ assert TencentModel3DProvider(CREDS).quote(n_views=3) == (30, 3.6)
+ assert TencentAutoRigProvider(Uploader(), CREDS).quote() == (10, 1.2)
+
+
+def test_unknown_generate_type_dies_at_construction():
+ with pytest.raises(ValueError, match="Normal"):
+ TencentModel3DProvider(CREDS, generate_type="UltraPro")
+
+
+# ── 图生 3D:请求形状 ───────────────────────────────────────────────────────
+
+
+def test_build_params_shape():
+ p = TencentModel3DProvider(CREDS, generate_type="LowPoly", face_count=90000)
+ params = p.build_params(b"\x89PNG-master")
+ assert params["GenerateType"] == "LowPoly"
+ assert params["FaceCount"] == 90000
+ assert "EnablePBR" not in params
+ import base64
+ assert base64.b64decode(params["ImageBase64"]) == b"\x89PNG-master"
+
+
+def test_multi_view_params_and_view_type_guard():
+ p = TencentModel3DProvider(CREDS)
+ params = p.build_params(b"front", extra_views={"back": b"b", "right": b"r"})
+ assert [v["ViewType"] for v in params["MultiViewImages"]] == ["back", "right"]
+ with pytest.raises(ValueError, match="ViewType"):
+ p.build_params(b"front", extra_views={"front": b"f"}) # 正面走主参数
+
+
+def test_oversize_master_is_refused():
+ p = TencentModel3DProvider(CREDS)
+ with pytest.raises(ValueError, match="ImageBase64"):
+ p.build_params(b"x" * (T.MAX_IMAGE_BYTES + 1))
+
+
+# ── 图生 3D:状态机与产物 ──────────────────────────────────────────────────
+
+
+def test_model3d_happy_path(cloud):
+ fake = cloud(
+ SubmitHunyuanTo3DProJob=[{"JobId": "job-1"}],
+ QueryHunyuanTo3DProJob=[
+ {"Status": "WAIT"}, {"Status": "RUN"},
+ {"Status": "DONE", "ResultFile3Ds": [{"Type": "GLB", "Url": "https://x/m.glb"}]},
+ ],
+ )
+ _DOWNLOADS["https://x/m.glb"] = GLB
+ out = TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG")
+ assert out == GLB
+ assert fake.count("QueryHunyuanTo3DProJob") == 3
+ # 默认**不发** ResultFormat:没有实测证据说接口收这个参数,而"要哪种格式"已经由取件端
+ # (按 Type 挑 + magic 复核)保证。发没验证过的参数只增加被拒风险。
+ assert "ResultFormat" not in fake.params_for("SubmitHunyuanTo3DProJob")
+
+
+def test_result_format_is_opt_in(cloud):
+ fake = cloud(SubmitHunyuanTo3DProJob=[{"JobId": "j"}],
+ QueryHunyuanTo3DProJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "GLB", "Url": "https://x/m.glb"}]}])
+ p = TencentModel3DProvider(CREDS, allow_spend=True, request_result_format=True)
+ p.image_to_3d(b"\x89PNG", want="GLB")
+ assert fake.params_for("SubmitHunyuanTo3DProJob")["ResultFormat"] == "GLB"
+
+
+def test_wrong_format_artifact_is_refused_not_returned(cloud):
+ """**踩过的坑**:请求 GLB,``ResultFile3Ds[0]`` 是 FBX。当时按 .glb 存了下来,
+ Blender 报 "Bad glTF"、出帧台超时,排查方向被带到"出帧管线坏了"。
+ bytes 版没有后缀可改,只能抛。"""
+ cloud(
+ SubmitHunyuanTo3DProJob=[{"JobId": "j"}],
+ QueryHunyuanTo3DProJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "FBX", "Url": "https://x/m.fbx"},
+ {"Type": "OBJ", "Url": "https://x/m.zip"},
+ ]}],
+ )
+ with pytest.raises(ArtifactFormatError) as e:
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG", want="GLB")
+ assert "FBX" in str(e.value) and "OBJ" in str(e.value)
+
+
+def test_billed_format_mismatch_hands_back_the_job_id(cloud):
+ """格式不符时**必须带出 JobId**。此时任务已 DONE、积分已经扣掉,拿不到 JobId 就
+ 只能重新提交、重付一次 —— 这正是本模块提供 ``fetch(job_id)`` 要防的那个缺口。
+ 绑骨路径一直带着 job_id,图生 3D 这条曾经漏了。
+ """
+ cloud(
+ SubmitHunyuanTo3DProJob=[{"JobId": "j-billed-42"}],
+ QueryHunyuanTo3DProJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "FBX", "Url": "https://x/m.fbx"},
+ ]}],
+ )
+ with pytest.raises(ArtifactFormatError) as e:
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG", want="GLB")
+ msg = str(e.value)
+ assert "j-billed-42" in msg, f"错误里没有 JobId,已扣费的产物取不回来:{msg}"
+ assert "费用已产生" in msg
+
+
+def test_artifact_is_picked_by_type_not_by_position(cloud):
+ """要 GLB 而 GLB 排在第二个:必须挑对,不是取 files[0]。"""
+ cloud(
+ SubmitHunyuanTo3DProJob=[{"JobId": "j"}],
+ QueryHunyuanTo3DProJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "FBX", "Url": "https://x/m.fbx"},
+ {"Type": "GLB", "Url": "https://x/m.glb"},
+ ]}],
+ )
+ _DOWNLOADS.update({"https://x/m.fbx": FBX, "https://x/m.glb": GLB})
+ assert TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG") == GLB
+
+
+def test_magic_bytes_override_the_vendors_claim(cloud):
+ """产物自称 GLB,内容却是 FBX。``Type`` 是供应商的自述,magic 才是事实 ——
+ 这一层是上面那个坑的另一半(挑对了标签也可能拿错内容)。"""
+ cloud(
+ SubmitHunyuanTo3DProJob=[{"JobId": "j"}],
+ QueryHunyuanTo3DProJob=[{"Status": "DONE",
+ "ResultFile3Ds": [{"Type": "GLB", "Url": "https://x/lie.glb"}]}],
+ )
+ _DOWNLOADS["https://x/lie.glb"] = FBX
+ with pytest.raises(ArtifactFormatError, match="自称"):
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG")
+
+
+def test_unknown_status_fails_loudly_with_jobid(cloud):
+ """认不出的状态一律当失败,而且必须带 JobId —— 一直 continue 会转到超时,
+ 把"协议变了"伪装成"生成太慢";带着 JobId 抛出去,任务还能再查。"""
+ cloud(SubmitHunyuanTo3DProJob=[{"JobId": "job-x"}],
+ QueryHunyuanTo3DProJob=[{"Status": "PAUSED_BY_ALIENS"}])
+ with pytest.raises(JobFailedError) as e:
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG")
+ assert "job-x" in str(e.value) and "PAUSED_BY_ALIENS" in str(e.value)
+
+
+def test_fail_status_reports_vendor_message(cloud):
+ cloud(SubmitHunyuanTo3DProJob=[{"JobId": "j"}],
+ QueryHunyuanTo3DProJob=[{"Status": "FAIL", "ErrorMessage": "图里没有主体"}])
+ with pytest.raises(JobFailedError, match="图里没有主体"):
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG")
+
+
+def test_timeout_says_credits_may_be_gone(cloud):
+ cloud(SubmitHunyuanTo3DProJob=[{"JobId": "j"}], QueryHunyuanTo3DProJob=[{"Status": "RUN"}])
+ p = TencentModel3DProvider(CREDS, allow_spend=True, poll_interval=1, max_min=1)
+ with pytest.raises(JobTimeoutError, match="积分可能已经扣了"):
+ p.image_to_3d(b"\x89PNG")
+
+
+def test_done_with_no_artifacts_is_a_failure(cloud):
+ cloud(SubmitHunyuanTo3DProJob=[{"JobId": "j"}],
+ QueryHunyuanTo3DProJob=[{"Status": "DONE", "ResultFile3Ds": []}])
+ with pytest.raises(JobFailedError, match="无产物"):
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG")
+
+
+def test_insufficient_credits_is_its_own_error(cloud):
+ """积分不足极易被误判成"接口坏了"。修法是充值,不是改代码 —— 所以单独一类。"""
+ cloud(SubmitHunyuanTo3DProJob=[
+ {"Error": {"Code": "ResourceInsufficient", "Message": "积分余额不足"}}])
+ with pytest.raises(InsufficientCreditsError, match="充值"):
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG")
+
+
+def test_bad_want_format_is_refused_before_anything(cloud):
+ fake = cloud()
+ with pytest.raises(ArtifactFormatError):
+ TencentModel3DProvider(CREDS, allow_spend=True).image_to_3d(b"\x89PNG", want="OBJ")
+ assert fake.calls == []
+
+
+# ── 绑骨 ────────────────────────────────────────────────────────────────────
+
+
+def test_autorig_happy_path_sniffs_input_and_returns_rigged_model(cloud):
+ fake = cloud(
+ SubmitAutoRiggingJob=[{"JobId": "rig-1"}],
+ DescribeAutoRiggingJob=[
+ {"Status": "RUN"},
+ {"Status": "DONE", "ResultFile3Ds": [{"Type": "GLB", "Url": "https://x/r.glb"}]},
+ ],
+ )
+ _DOWNLOADS["https://x/r.glb"] = GLB
+ up = Uploader()
+ got = TencentAutoRigProvider(up, CREDS, allow_spend=True).rig(make_glb(), motion="walk")
+ assert isinstance(got, RiggedModel)
+ assert got.fmt == "GLB" and got.data == GLB
+ assert got.motion == PresetMotion("walk", 23)
+ sub = fake.params_for("SubmitAutoRiggingJob")
+ assert sub["File3D"]["Type"] == "GLB" # 由 magic 嗅出来,不由调用方声明
+ assert sub["MotionType"] == 23
+ assert up.seen == [(len(make_glb()), "model/gltf-binary")]
+
+
+def test_autorig_no_motion_means_no_motiontype_param(cloud):
+ fake = cloud(SubmitAutoRiggingJob=[{"JobId": "j"}],
+ DescribeAutoRiggingJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "GLB", "Url": "https://x/r.glb"}]}])
+ got = TencentAutoRigProvider(Uploader(), CREDS, allow_spend=True).rig(make_glb())
+ assert got.motion is None
+ assert "MotionType" not in fake.params_for("SubmitAutoRiggingJob")
+
+
+def test_autorig_wrong_format_artifact_is_refused(cloud):
+ """同一个坑在绑骨这一侧的原始形态:请求 GLB 输入,拿回来的 files[0] 是 FBX。"""
+ cloud(SubmitAutoRiggingJob=[{"JobId": "j"}],
+ DescribeAutoRiggingJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "FBX", "Url": "https://x/r.fbx"}]}])
+ with pytest.raises(ArtifactFormatError, match="FBX"):
+ TencentAutoRigProvider(Uploader(), CREDS, allow_spend=True).rig(make_glb(), want="GLB")
+
+
+def test_autorig_can_ask_for_fbx(cloud):
+ cloud(SubmitAutoRiggingJob=[{"JobId": "j"}],
+ DescribeAutoRiggingJob=[{"Status": "DONE", "ResultFile3Ds": [
+ {"Type": "GLB", "Url": "https://x/r.glb"},
+ {"Type": "FBX", "Url": "https://x/r.fbx"}]}])
+ _DOWNLOADS.update({"https://x/r.glb": GLB, "https://x/r.fbx": FBX})
+ got = TencentAutoRigProvider(Uploader(), CREDS, allow_spend=True).rig(make_glb(), want="FBX")
+ assert got.fmt == "FBX" and got.data == FBX
+
+
+def test_non_public_uploader_url_is_refused_before_submit(cloud):
+ fake = cloud()
+ up = Uploader(url="/tmp/local/model.glb")
+ with pytest.raises(ModelNotPublicError, match="http"):
+ TencentAutoRigProvider(up, CREDS, allow_spend=True).rig(make_glb())
+ assert fake.calls == [] # 没提交 = 没占配额
+
+
+def test_error_text_does_not_leak_the_signature(cloud):
+ """预签名 URL 里 ``q-ak`` **就是 SecretId**。任何回显 URL 的地方都必须先脱敏,
+ 否则一条错误日志等于把半副凭证写进了日志文件。"""
+ cloud()
+ leaky = "ftp://b.cos/x.glb?q-ak=AKIDrealsecret&q-signature=deadbeefcafe"
+ with pytest.raises(ModelNotPublicError) as e:
+ TencentAutoRigProvider(Uploader(url=leaky), CREDS, allow_spend=True).rig(make_glb())
+ assert "AKIDrealsecret" not in str(e.value)
+ assert "deadbeefcafe" not in str(e.value)
+
+
+def test_redact_keeps_shape_drops_values():
+ out = redact("https://h/k?q-sign-algorithm=sha1&q-ak=AKIDxyz&q-signature=abc123&t=1")
+ assert "AKIDxyz" not in out and "abc123" not in out
+ assert "q-ak=" in out and "q-sign-algorithm=sha1" in out and "t=1" in out
+
+
+def test_credentials_never_render_in_repr():
+ """provider 出错时的 traceback 常带上构造参数 —— dataclass 默认 repr 会把 key 打出来。"""
+ text = repr(TencentCredentials("AKIDsecret", "keysecret"))
+ assert "AKIDsecret" not in text and "keysecret" not in text
+
+
+# ── 预设动作 ────────────────────────────────────────────────────────────────
+
+
+def test_preset_motions_all_declare_no_root_motion():
+ """48 个预设**全部零根位移**(跑步、向前大跳实测都是 0)。所以 root_motion 只能由
+ 管线算或人工设 —— 这条事实写进类型里,别指望接口给。"""
+ presets = TencentAutoRigProvider(Uploader(), CREDS).preset_motions
+ assert presets and all(not m.has_root_motion for m in presets.values())
+ assert presets["walk"].motion_type == 23
+
+
+def test_motion_resolution_by_name_and_number():
+ p = TencentAutoRigProvider(Uploader(), CREDS)
+ assert p.resolve_motion("run") == PresetMotion("run", 34)
+ assert p.resolve_motion(26) == PresetMotion("idle", 26) # 认得的编号回填名字
+ assert p.resolve_motion(7) == PresetMotion("motion_7", 7) # 没登记名字的照样能用
+ assert p.resolve_motion(None) is None
+
+
+def test_motion_number_out_of_range_is_refused():
+ p = TencentAutoRigProvider(Uploader(), CREDS)
+ for bad in (0, 49, -1):
+ with pytest.raises(ValueError, match="1–48"):
+ p.resolve_motion(bad)
+
+
+def test_unknown_motion_name_lists_what_exists():
+ p = TencentAutoRigProvider(Uploader(), CREDS)
+ with pytest.raises(KeyError) as e:
+ p.resolve_motion("moonwalk")
+ assert "walk" in str(e.value) and "48" in str(e.value)
+
+
+# ── uploader ────────────────────────────────────────────────────────────────
+
+
+def test_cos_uploader_key_is_content_addressed(monkeypatch):
+ """key 用内容哈希:同一份模型重传不堆副本,重试天然幂等。"""
+ import hashlib
+
+ up = T.TencentCosModelUploader(CREDS)
+ monkeypatch.setattr(up, "appid", lambda: "1300000000")
+ seen: list[tuple[str, str]] = []
+ monkeypatch.setattr(T, "cos_request",
+ lambda creds, method, uri, host, data=None, timeout=300:
+ (seen.append((method, uri)), (200, ""))[1])
+ url = up.upload(GLB, "model/gltf-binary")
+ digest = hashlib.sha256(GLB).hexdigest()[:32]
+ assert seen == [("PUT", "/"), ("PUT", f"/{digest}.glb")]
+ assert url.startswith(f"https://windup-rig-1300000000.cos.ap-guangzhou.myqcloud.com/{digest}.glb?")
+ assert "q-signature=" in url
+
+
+def test_cos_uploader_surfaces_upload_failure(monkeypatch):
+ up = T.TencentCosModelUploader(CREDS)
+ monkeypatch.setattr(up, "appid", lambda: "1300000000")
+ monkeypatch.setattr(T, "cos_request",
+ lambda creds, method, uri, host, data=None, timeout=300:
+ (200, "") if uri == "/" else (403, "AccessDenied"))
+ with pytest.raises(JobFailedError, match="403"):
+ up.upload(GLB, "model/gltf-binary")
+
+
+def test_every_extra_view_is_size_checked_before_paying():
+ """超限的侧/背视要在提交前炸:走到接口才被拒的话钱已经花了。"""
+ from windup_framework.providers.render3d.tencent import MAX_IMAGE_BYTES
+
+ p = TencentModel3DProvider(CREDS, allow_spend=True)
+ ok, oversize = b"\x89PNG" + b"x", b"\x89PNG" + b"x" * MAX_IMAGE_BYTES
+ p.build_params(ok, {"back": ok}) # 都不超限:不该抛
+ with pytest.raises(ValueError, match="back"):
+ p.build_params(ok, {"back": oversize})
+ with pytest.raises(ValueError, match="master"):
+ p.build_params(oversize, {"back": ok})
+
+
+# ── 凭证与签名:纯计算、不联网,而且都是安全相关 ─────────────────────────────
+
+
+def test_credentials_never_leak_into_repr():
+ """凭证的 repr 不许带出密钥 —— provider 出错时 traceback 常常带上构造参数。"""
+ from windup_framework.providers.render3d import TencentCredentials
+
+ c = TencentCredentials("AKID_secret_id", "super_secret_key")
+ assert "super_secret_key" not in repr(c)
+ assert "AKID_secret_id" not in repr(c)
+
+
+def test_credentials_resolve_prefers_env_then_file(tmp_path, monkeypatch):
+ from windup_framework.providers.render3d import TencentCredentials
+ from windup_framework.providers.render3d import _tc3
+
+ monkeypatch.setenv("TENCENT_SECRET_ID", "env_id")
+ monkeypatch.setenv("TENCENT_SECRET_KEY", "env_key")
+ c = TencentCredentials.resolve()
+ assert (c.secret_id, c.secret_key) == ("env_id", "env_key")
+
+ monkeypatch.delenv("TENCENT_SECRET_ID")
+ monkeypatch.delenv("TENCENT_SECRET_KEY")
+ envfile = tmp_path / "tencent.env"
+ envfile.write_text("# 注释行\nTENCENT_SECRET_ID=file_id\nTENCENT_SECRET_KEY=file_key\n")
+ monkeypatch.setattr(_tc3, "ENVFILE", envfile)
+ c = TencentCredentials.resolve()
+ assert (c.secret_id, c.secret_key) == ("file_id", "file_key")
+
+
+def test_missing_credentials_say_what_to_set(tmp_path, monkeypatch):
+ """两处都没有就抛,不静默用空串 —— 空串会得到一个看不懂的鉴权错。"""
+ from windup_framework.providers.render3d import TencentCredentials
+ from windup_framework.providers.render3d import _tc3
+
+ monkeypatch.delenv("TENCENT_SECRET_ID", raising=False)
+ monkeypatch.delenv("TENCENT_SECRET_KEY", raising=False)
+ monkeypatch.setattr(_tc3, "ENVFILE", tmp_path / "nope.env")
+ with pytest.raises(RuntimeError, match="TENCENT_SECRET_ID"):
+ TencentCredentials.resolve()
+
+
+def test_redact_hides_signature_and_key_query_params():
+ """日志/异常里回显 URL 时,预签名参数必须被抹掉。"""
+ from windup_framework.providers.render3d import redact
+
+ url = ("https://b.cos.example.com/m.glb?q-sign-algorithm=sha1"
+ "&q-ak=AKIDxxxx&q-signature=deadbeefcafe&q-key-time=1&x=ok")
+ out = redact(url)
+ assert "deadbeefcafe" not in out
+ assert "AKIDxxxx" not in out
+ assert "x=ok" in out, "不该把无关参数也抹掉"
+
+
+def test_api_error_keeps_the_vendor_code():
+ """错误码要原样带出去 —— 上层按它区分"积分不足"与"接口坏了"。"""
+ from windup_framework.providers.render3d import TencentApiError
+
+ e = TencentApiError("ResourceInsufficient", "余额不足")
+ assert e.code == "ResourceInsufficient"
+ assert "余额不足" in str(e)
+
+
+# ── TC3 签名与重试(离线:替掉 urlopen) ────────────────────────────────────────
+
+
+def _fake_http_error(code, body):
+ import urllib.error
+ return urllib.error.HTTPError("https://x", code, "err", {}, io.BytesIO(body.encode()))
+
+
+class _Resp:
+ def __init__(self, body):
+ self._b = body.encode()
+ self.status = 200
+
+ def read(self):
+ return self._b
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+
+def _creds():
+ from windup_framework.providers.render3d._tc3 import TencentCredentials
+ return TencentCredentials("AKIDtest", "secrettest", "ap-guangzhou")
+
+
+def test_business_error_is_returned_not_retried(monkeypatch):
+ """HTTPError = 业务错误。**不能重试** —— 提交类接口重发会重复扣积分,
+ 而这条线的每次提交都是真金白银。"""
+ from windup_framework.providers.render3d import _tc3
+
+ calls = []
+
+ def _one_shot(req, timeout=None):
+ calls.append(1)
+ raise _fake_http_error(400, '{"Response":{"Error":{"Code":"InvalidParameter"}}}')
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _one_shot)
+ out = _tc3.call("Submit", {}, service="ai3d", version="2025-05-13", creds=_creds())
+ assert out["Error"]["Code"] == "InvalidParameter"
+ assert len(calls) == 1, "业务错误重试了,提交类接口会重复扣费"
+
+
+def test_network_error_retries_then_raises(monkeypatch):
+ from windup_framework.providers.render3d import _tc3
+
+ calls = []
+
+ def _flaky(req, timeout=None):
+ calls.append(1)
+ raise OSError("connection reset")
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _flaky)
+ monkeypatch.setattr(_tc3.time, "sleep", lambda *_: None)
+ with pytest.raises(OSError):
+ _tc3.call("Q", {}, service="ai3d", version="v", creds=_creds(), retries=3)
+ assert len(calls) == 3
+
+
+def test_network_error_recovers_within_budget(monkeypatch):
+ from windup_framework.providers.render3d import _tc3
+
+ state = {"n": 0}
+
+ def _second_time_lucky(req, timeout=None):
+ state["n"] += 1
+ if state["n"] == 1:
+ raise OSError("reset")
+ return _Resp('{"Response":{"JobId":"j-1"}}')
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _second_time_lucky)
+ monkeypatch.setattr(_tc3.time, "sleep", lambda *_: None)
+ assert _tc3.call("Q", {}, service="ai3d", version="v", creds=_creds())["JobId"] == "j-1"
+
+
+def test_signature_headers_are_well_formed(monkeypatch):
+ """签名错了只会得到一句 AuthFailure,查不出错在哪一段;这里把结构钉死。"""
+ from windup_framework.providers.render3d import _tc3
+
+ seen = {}
+
+ def _capture(req, timeout=None):
+ seen.update(req.headers)
+ seen["__data"] = req.data
+ return _Resp('{"Response":{}}')
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _capture)
+ _tc3.call("TestAction", {"A": 1}, service="ai3d", version="2025-05-13", creds=_creds())
+ auth = seen["Authorization"]
+ assert auth.startswith("TC3-HMAC-SHA256 Credential=AKIDtest/")
+ assert "SignedHeaders=content-type;host;x-tc-action" in auth
+ assert "/ai3d/tc3_request" in auth
+ assert seen["X-tc-action"] == "TestAction"
+ assert seen["X-tc-region"] == "ap-guangzhou"
+
+
+def test_cos_request_returns_status_and_body_on_error(monkeypatch):
+ """COS 失败要**带回状态码和响应体**:绑骨只接受公网可拉取的 URL,
+ 这一步失败时若只抛一句"上传失败",分不清是签名错还是桶策略错。"""
+ from windup_framework.providers.render3d import _tc3
+
+ monkeypatch.setattr(
+ _tc3.urllib.request, "urlopen",
+ lambda *a, **k: (_ for _ in ()).throw(_fake_http_error(403, "AccessDenied")))
+ code, body = _tc3.cos_request(_creds(), "PUT", "/o.glb", "b.cos.ap-guangzhou.myqcloud.com", b"x")
+ assert code == 403 and "AccessDenied" in body
+
+
+def test_cos_signature_carries_the_required_fields():
+ from windup_framework.providers.render3d._tc3 import cos_sign
+
+ sig = cos_sign(_creds(), "PUT", "/o.glb", "b.cos.ap-guangzhou.myqcloud.com")
+ for field in ("q-sign-algorithm=sha1", "q-ak=AKIDtest", "q-header-list=host", "q-signature="):
+ assert field in sig
+
+
+# ── 评审补强(#270 FennoAI)────────────────────────────────────────────────
+
+
+def test_non_https_artifact_url_is_refused_before_download():
+ """产物 URL 来自接口响应,不是我们拼的。urllib 会照单全收 file:// 与内网地址、
+ 且默认跟随跳转 —— 上游被污染时,那些内容会被当作"模型 bytes"交到下游。
+
+ 这里取**未打桩**的原函数:``no_real_download`` 是 autouse 的,直接调 ``T._download``
+ 测到的是那个假实现,断言会空跑成绿的。
+ """
+ for bad in ("file:///etc/passwd", "http://169.254.169.254/latest/meta-data/"):
+ with pytest.raises(ValueError, match="不是 https"):
+ _REAL_DOWNLOAD(bad)
+
+
+def test_gateway_5xx_is_not_retried_for_submits(monkeypatch):
+ """5xx 意味着请求**可能已经到达后端**。提交类重发会重复扣积分,故默认不重试。"""
+ from windup_framework.providers.render3d import _tc3
+
+ calls = []
+
+ def _gateway_down(req, timeout=None):
+ calls.append(1)
+ raise _fake_http_error(502, "bad gateway")
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _gateway_down)
+ monkeypatch.setattr(_tc3.time, "sleep", lambda *_: None)
+ with pytest.raises(Exception):
+ _tc3.call("Submit", {}, service="ai3d", version="v", creds=_creds())
+ assert len(calls) == 1, "提交类在 5xx 上重试了,会重复扣费"
+
+
+def test_gateway_5xx_is_retried_for_idempotent_calls(monkeypatch):
+ """查询/取件是幂等的,重试的代价只是一次重查。"""
+ from windup_framework.providers.render3d import _tc3
+
+ state = {"n": 0}
+
+ def _flaky(req, timeout=None):
+ state["n"] += 1
+ if state["n"] < 3:
+ raise _fake_http_error(503, "unavailable")
+ return _Resp('{"Response":{"Status":"DONE"}}')
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _flaky)
+ monkeypatch.setattr(_tc3.time, "sleep", lambda *_: None)
+ out = _tc3.call("Query", {}, service="ai3d", version="v", creds=_creds(), idempotent=True)
+ assert out["Status"] == "DONE" and state["n"] == 3
+
+
+def test_throttling_is_retried_even_for_submits(monkeypatch):
+ """429 = 被网关挡下、后端没执行,重发不会重复扣费。"""
+ from windup_framework.providers.render3d import _tc3
+
+ state = {"n": 0}
+
+ def _throttled(req, timeout=None):
+ state["n"] += 1
+ if state["n"] == 1:
+ raise _fake_http_error(429, "throttled")
+ return _Resp('{"Response":{"JobId":"j-9"}}')
+
+ monkeypatch.setattr(_tc3.urllib.request, "urlopen", _throttled)
+ monkeypatch.setattr(_tc3.time, "sleep", lambda *_: None)
+ assert _tc3.call("Submit", {}, service="ai3d", version="v", creds=_creds())["JobId"] == "j-9"
+
+
+def test_env_file_values_tolerate_surrounding_quotes(tmp_path, monkeypatch):
+ """`KEY="AKID..."` 是常见 .env 写法;带引号的值只会换来一个看不懂的鉴权错。"""
+ from windup_framework.providers.render3d import _tc3
+
+ f = tmp_path / "tencent.env"
+ f.write_text('TENCENT_SECRET_ID="AKIDquoted"\nTENCENT_SECRET_KEY=\'skquoted\'\n')
+ monkeypatch.delenv("TENCENT_SECRET_ID", raising=False)
+ monkeypatch.delenv("TENCENT_SECRET_KEY", raising=False)
+ monkeypatch.setattr(_tc3, "ENVFILE", f)
+ c = _tc3.TencentCredentials.resolve()
+ assert c.secret_id == "AKIDquoted" and c.secret_key == "skquoted"
diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py
index 4a3411e7..d9f83f1c 100644
--- a/backend/tests/test_smoke.py
+++ b/backend/tests/test_smoke.py
@@ -1,6 +1,50 @@
+from unittest.mock import Mock
+
+from fastapi.testclient import TestClient
+
from windup_app.bootstrap.app import create_app
def test_create_app():
app = create_app()
assert app.title == "windup"
+
+
+def test_create_app_does_not_construct_chat_model(monkeypatch):
+ """CI 没有 AI_API_KEY,装配期不能去建 ChatOpenAI。"""
+ from windup_app.server.character.service import service as character_service
+
+ def boom(*_args, **_kwargs):
+ raise AssertionError("create_chat_model should not run during create_app")
+
+ monkeypatch.setattr(
+ "windup_ai_engine.impl.character_namer.create_chat_model",
+ boom,
+ )
+ character_service._namer = None
+ app = create_app()
+ assert app.title == "windup"
+
+
+def test_health_endpoint_reports_ok_without_auth(client):
+ response = client.get("/health")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+
+def test_lifespan_shuts_down_generation_dispatcher(monkeypatch):
+ import windup_app.bootstrap.app as app_module
+
+ create_all = Mock()
+ monkeypatch.setattr(app_module.Base.metadata, "create_all", create_all)
+ app = create_app()
+ dispatcher = Mock()
+ app.state.generation_dispatcher = dispatcher
+
+ with TestClient(app) as client:
+ assert client.get("/health").status_code == 200
+ dispatcher.shutdown.assert_not_called()
+
+ create_all.assert_called_once_with(app_module.engine)
+ dispatcher.shutdown.assert_called_once_with()
diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py
new file mode 100644
index 00000000..8443dab1
--- /dev/null
+++ b/backend/tests/test_sufy_video_download.py
@@ -0,0 +1,841 @@
+"""视频成品下载的凭证边界、重试与完整性校验(不联网:用 httpx MockTransport)。
+
+两个回归对象:
+
+1. 2026-08-05 实测两次连续复现:视频已生成、费用已产生,却因为读 body 时断了一次连接
+ 就整单丢弃。见 ``providers.sufy._download`` 的 docstring。
+2. 2026-08-10 机器审(PR #179 P1):成品 URL 是网关返回的绝对地址,复用带 Authorization
+ 的 client 去下载 = 把 API key 发给了 CDN(或网关返回的任意地址)。
+ 见 ``providers.sufy._download_request`` 的 docstring。
+"""
+
+from datetime import datetime, timezone
+import json
+
+import httpx
+import pytest
+
+from windup_framework.providers.sufy import (
+ IncompleteDownloadError,
+ UnsafeDownloadUrlError,
+ _download,
+ _retry_after_seconds,
+ _utc_now,
+)
+
+VIDEO = b"\x00\x01mp4-bytes" * 64
+GATEWAY = "https://gw.invalid/v1"
+
+
+def _client(handler) -> httpx.Client:
+ return httpx.Client(transport=httpx.MockTransport(handler))
+
+
+def _authed_client(handler, base_url: str = GATEWAY) -> httpx.Client:
+ """带凭证的网关 client —— provider 真正持有的就是这种(Authorization + cookie jar)。"""
+ return httpx.Client(
+ transport=httpx.MockTransport(handler),
+ base_url=base_url,
+ headers={"Authorization": "Key secret-api-key"},
+ cookies={"session": "s3cr3t"},
+ )
+
+
+def test_retries_after_peer_closed_connection(monkeypatch):
+ """第一次断连、第二次成功 —— 原实现在这里会整单丢弃。"""
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ calls = {"n": 0}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise httpx.RemoteProtocolError(
+ "peer closed connection without sending complete message body", request=request
+ )
+ return httpx.Response(200, content=VIDEO)
+
+ with _client(handler) as client:
+ assert _download(client, "https://example.invalid/v.mp4") == VIDEO
+ assert calls["n"] == 2
+
+
+def test_rejects_truncated_body_that_does_not_raise(monkeypatch):
+ """服务端声明的长度与实收不符时必须失败,而不是把坏视频往下游送。
+
+ 截断不一定抛异常。放过去的话,坏视频要到出帧环节才暴露成"解码失败",
+ 很难回溯到下载这一步。
+ """
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ # 只回一半 body,但 Content-Length 仍声明全长
+ return httpx.Response(
+ 200, content=VIDEO[: len(VIDEO) // 2], headers={"content-length": str(len(VIDEO))}
+ )
+
+ with _client(handler) as client, pytest.raises(RuntimeError, match="已重试 3 次"):
+ _download(client, "https://example.invalid/v.mp4")
+
+
+def test_accepts_chunked_response_without_content_length(monkeypatch):
+ """分块传输没有 Content-Length,此时跳过校验而不是误判为不完整。"""
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, stream=httpx.ByteStream(VIDEO))
+
+ with _client(handler) as client:
+ assert _download(client, "https://example.invalid/v.mp4") == VIDEO
+
+
+def test_gives_up_after_three_tries_and_reports_the_last_cause(monkeypatch):
+ """一直断连时要显式失败,并把最后一次的真实原因带出来。"""
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ calls = {"n": 0}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ calls["n"] += 1
+ raise httpx.ConnectError("connection reset", request=request)
+
+ with _client(handler) as client, pytest.raises(RuntimeError, match="connection reset"):
+ _download(client, "https://example.invalid/v.mp4")
+ assert calls["n"] == 3
+
+
+def test_incomplete_download_error_is_a_runtime_error():
+ """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。"""
+ assert issubclass(IncompleteDownloadError, RuntimeError)
+
+
+# ── 凭证边界:成品 URL 是网关给的外部地址,不能带着 API key 去取 ──────────────
+
+
+def test_cross_origin_download_does_not_leak_the_api_key(monkeypatch):
+ """跨源下载必须摘掉 client 级凭证。
+
+ 这是 PR #179 P1 的直接回归:httpx 只在跨源**重定向**时自动摘 Authorization,
+ 对一开始就跨源的直连请求会原样带上 —— 于是 CDN 域名收到了 API key。
+ """
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ seen: dict[str, str | None] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["authorization"] = request.headers.get("authorization")
+ seen["cookie"] = request.headers.get("cookie")
+ return httpx.Response(200, content=VIDEO)
+
+ with _authed_client(handler) as client:
+ assert _download(client, "https://cdn.invalid/out.mp4") == VIDEO
+
+ assert seen["authorization"] is None, "API key 被发给了 CDN"
+ assert seen["cookie"] is None, "会话 cookie 被发给了 CDN"
+
+
+def test_same_origin_download_keeps_the_gateway_credential(monkeypatch):
+ """同源(网关自己签发的下载链接)必须保留凭证,否则那条路径就是 401。
+
+ 一律摘头会把这个功能弄坏,所以判据是目标地址,不是"下载一律不带凭证"。
+ """
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ seen: list[str | None] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request.headers.get("authorization"))
+ return httpx.Response(200, content=VIDEO)
+
+ with _authed_client(handler) as client:
+ # 第二个地址显式写出默认端口 443。httpx 0.28 会把默认端口归一化掉(URL.port -> None),
+ # 所以这条今天走不到"补默认端口"那行;留着是钉住这个前提 —— httpx 哪天不再归一化,
+ # 少了默认端口补齐就会把它误判成跨源、把凭证摘掉,这条会先叫。
+ assert _download(client, "https://gw.invalid/files/out.mp4") == VIDEO
+ assert _download(client, "https://gw.invalid:443/files/out.mp4") == VIDEO
+
+ assert seen == ["Key secret-api-key", "Key secret-api-key"]
+
+
+def test_downgrade_to_plain_http_is_treated_as_cross_origin(monkeypatch):
+ """同 host 但 scheme 从 https 掉到 http —— 也要摘凭证。
+
+ 默认端口被 httpx 归一化成 None,host 又相同,所以同源判定里**少比一个 scheme**
+ 就会把它当自己人,于是 API key 走明文 HTTP 发出去。httpx 自己在重定向那侧也是
+ 单独处理 http/https 的(``_is_https_redirect``),方向只允许 http→https,不允许反过来。
+ """
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ seen: dict[str, str | None] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, content=VIDEO)
+
+ with _authed_client(handler) as client:
+ assert _download(client, "http://gw.invalid/files/out.mp4") == VIDEO
+ assert seen["authorization"] is None, "API key 走明文 HTTP 发了出去"
+
+ # 再来一格显式非默认端口:两边端口都是 8443,"补默认端口"那行判不出差别,
+ # 只有 scheme 比较能拦住。少了这一格,scheme 比较会显得可以删(实际不行)。
+ with _authed_client(handler, base_url="https://gw.invalid:8443/v1") as client:
+ assert _download(client, "http://gw.invalid:8443/files/out.mp4") == VIDEO
+ assert seen["authorization"] is None, "非默认端口上的 https->http 降级没拦住"
+
+
+def test_relative_result_path_stays_authenticated(monkeypatch):
+ """网关返回相对路径时,它解析到网关自己身上,凭证照带。"""
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ seen: dict[str, str | None] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen["url"] = str(request.url)
+ seen["authorization"] = request.headers.get("authorization")
+ return httpx.Response(200, content=VIDEO)
+
+ with _authed_client(handler) as client:
+ assert _download(client, "files/out.mp4") == VIDEO
+
+ assert seen["url"] == "https://gw.invalid/v1/files/out.mp4"
+ assert seen["authorization"] == "Key secret-api-key"
+
+
+def test_non_http_result_url_is_refused_before_any_request_goes_out(monkeypatch):
+ """协议不是 http(s) 就不发请求 —— 地址不对要立刻炸,不是重试三次后报传输错。
+
+ 注意 httpx 的边界:只有**带 host** 的绝对地址才保留原 scheme(``ftp://cdn/...``);
+ ``file:///etc/passwd`` 这种没有 host 的会被 httpx 当相对地址并入 base_url,
+ 结果是一个打到网关的 404,不经过这个分支。
+ """
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise AssertionError(f"不该发出任何请求: {request.url}")
+
+ for url in ("ftp://cdn.invalid/out.mp4", "file://cdn.invalid/out.mp4"):
+ with _authed_client(handler) as client:
+ with pytest.raises(UnsafeDownloadUrlError, match="http"):
+ _download(client, url)
+
+
+# ── 文生图 provider(2026-08-10 实现;此前 gen_image 必抛错而端点可达)──────────
+
+
+def _img_payload(b64: str) -> dict:
+ """模型把图放在 message.content 里,不同网关包裹层级不同。"""
+ return {"choices": [{"message": {"content": f"data:image/png;base64,{b64}"}}]}
+
+
+def _big_b64(n: int = 6000) -> str:
+ import base64
+ return base64.b64encode(b"\x89PNG" + b"\x00" * n).decode()
+
+
+# Cloudflare 边缘自己生成 52x 时带的两个头 —— 用来钉"带不带它都一样重发"。
+_CF_EDGE = {"cf-ray": "8f2b1c4d5e6a7890-SJC", "server": "cloudflare"}
+
+
+def _image_provider(handler):
+ import httpx
+
+ from windup_framework.config.provider import AIProviderSettings
+ from windup_framework.providers.sufy import SufyImageProvider
+
+ p = SufyImageProvider(
+ config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k"),
+ )
+ client = httpx.Client(
+ base_url="https://gw.example.com/v1",
+ headers={"Authorization": "Bearer k"},
+ transport=httpx.MockTransport(handler),
+ )
+ p._client = lambda: client
+ return p
+
+
+def test_image_provider_extends_request_timeout_by_half():
+ from windup_framework.config.provider import AIProviderSettings
+ from windup_framework.providers.sufy import SufyImageProvider
+
+ provider = SufyImageProvider(
+ config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k", timeout=20),
+ )
+
+ with provider._client() as client:
+ assert client.timeout.connect == 30
+ assert client.timeout.read == 30
+ assert client.timeout.write == 30
+ assert client.timeout.pool == 30
+
+
+def test_gen_image_returns_the_decoded_png():
+ """端点可达而 provider 必抛错 = 每个图像任务稳定 FAILED。实现后必须真能出图。"""
+ def h(request):
+ import httpx
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ data = _image_provider(h).gen_image("a knight", [])
+ assert data.startswith(b"\x89PNG") and len(data) > 5000
+
+
+def test_reference_images_are_sent_as_data_uris():
+ """参考图走 content 数组里的 image_url,不是 multipart、不是单独字段。"""
+ import json as _json
+
+ seen: dict = {}
+
+ def h(request):
+ import httpx
+ seen["body"] = _json.loads(request.content)
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ _image_provider(h).gen_image("x", [b"\x89PNGref"])
+ content = seen["body"]["messages"][0]["content"]
+ kinds = [c["type"] for c in content]
+ assert kinds == ["text", "image_url"]
+ assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
+
+
+def test_response_without_an_image_is_retried_then_raises():
+ """模型偶发返回一条不含图的正常响应。重试后仍拿不到必须抛,不能返回空 bytes——
+ 上游会把返回值直接上传对象存储并写进任务结果,0 字节的"成功"就是用户看到的裂图。"""
+ import pytest
+
+ calls = {"n": 0}
+
+ def h(request):
+ import httpx
+ calls["n"] += 1
+ return httpx.Response(200, json={"choices": [{"message": {"content": "抱歉"}}]})
+
+ with pytest.raises(RuntimeError, match="未取得有效图"):
+ _image_provider(h).gen_image("x", [])
+ assert calls["n"] == 3, "应重试到上限而不是一次就放弃"
+
+
+def test_undersized_image_is_rejected_not_returned():
+ """响应里可能带一个几十字节的占位串,当图存下去就是打不开的文件。"""
+ import base64
+
+ import pytest
+
+ tiny = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode()
+
+ def h(request):
+ import httpx
+ return httpx.Response(200, json=_img_payload(tiny))
+
+ with pytest.raises(RuntimeError, match="字节"):
+ _image_provider(h).gen_image("x", [])
+
+
+def test_first_successful_attempt_stops_retrying():
+ calls = {"n": 0}
+
+ def h(request):
+ import httpx
+ calls["n"] += 1
+ if calls["n"] == 1:
+ return httpx.Response(200, json={"choices": [{"message": {"content": "空"}}]})
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ assert _image_provider(h).gen_image("x", [])
+ assert calls["n"] == 2
+
+
+def test_image_client_retries_connection_failures():
+ """本机走代理时建连抖动常见;已跑通的管线实现靠一层网络重试扛住。
+
+ 只断言"配了连接重试"这个结构 —— 真去模拟 SSL 握手失败需要一个假 TCP 端点,
+ 那验的是 httpx 而不是我们的代码。
+ """
+ from windup_framework.providers.sufy import _CONNECT_RETRIES, SufyImageProvider
+
+ assert _CONNECT_RETRIES >= 1
+ client = SufyImageProvider()._client()
+ try:
+ assert client._transport._pool._retries == _CONNECT_RETRIES
+ finally:
+ client.close()
+
+
+def test_image_rate_limit_is_retried_after_retry_after(monkeypatch):
+ """429 表示请求未被网关接收,按 Retry-After 退避后应继续当前图片任务。"""
+ calls = {"n": 0}
+ sleeps: list[float] = []
+
+ def h(request):
+ import httpx
+ calls["n"] += 1
+ if calls["n"] == 1:
+ return httpx.Response(429, headers={"Retry-After": "0.25"})
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", sleeps.append)
+
+ assert _image_provider(h).gen_image("x", [])
+ assert calls["n"] == 2
+ assert sleeps == [0.25]
+
+
+def test_image_rate_limit_exhaustion_has_actionable_error(monkeypatch):
+ """持续 429 不能泄漏 httpx 异常,也不能无限重试。"""
+ calls = {"n": 0}
+
+ def h(request):
+ import httpx
+ calls["n"] += 1
+ return httpx.Response(429, text='{"error":{"message":"quota exceeded"}}')
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ with pytest.raises(RuntimeError, match="稍后重试或检查服务商额度"):
+ _image_provider(h).gen_image("x", [])
+ assert calls["n"] == 3
+
+
+@pytest.mark.parametrize(
+ ("retry_after", "expected"), [("invalid", 2.0), ("NaN", 2.0), ("300", 30.0)]
+)
+def test_image_rate_limit_wait_has_fallback_and_cap(monkeypatch, retry_after, expected):
+ calls = {"n": 0}
+ sleeps: list[float] = []
+
+ def h(request):
+ import httpx
+ calls["n"] += 1
+ if calls["n"] == 1:
+ return httpx.Response(429, headers={"Retry-After": retry_after})
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", sleeps.append)
+
+ assert _image_provider(h).gen_image("x", [])
+ assert sleeps == [expected]
+
+
+def test_image_rate_limit_accepts_http_date(monkeypatch):
+ calls = {"n": 0}
+ sleeps: list[float] = []
+
+ def h(request):
+ import httpx
+ calls["n"] += 1
+ if calls["n"] == 1:
+ return httpx.Response(
+ 429, headers={"Retry-After": "Thu, 13 Aug 2026 03:00:10 GMT"}
+ )
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ monkeypatch.setattr(
+ "windup_framework.providers.sufy._utc_now",
+ lambda: datetime(2026, 8, 13, 3, 0, tzinfo=timezone.utc),
+ )
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", sleeps.append)
+
+ assert _image_provider(h).gen_image("x", [])
+ assert sleeps == [10.0]
+
+
+def test_retry_after_clock_is_utc():
+ assert _utc_now().tzinfo is timezone.utc
+
+
+def test_retry_after_accepts_date_without_timezone(monkeypatch):
+ monkeypatch.setattr(
+ "windup_framework.providers.sufy._utc_now",
+ lambda: datetime(2026, 8, 13, 3, 0, tzinfo=timezone.utc),
+ )
+
+ assert _retry_after_seconds("Thu, 13 Aug 2026 03:00:10") == 10.0
+
+
+def test_request_path_comes_from_config_not_a_literal():
+ """路径用配置里的 chat_completions_path —— 它此前零消费方,正是今天在删的那类字段。"""
+
+ seen: dict = {}
+
+ def h(request):
+ import httpx
+ seen["path"] = request.url.path
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ p = _image_provider(h)
+ p._cfg = p._cfg.model_copy(update={"chat_completions_path": "/v9/custom-chat"})
+ p.gen_image("x", [])
+ assert seen["path"].endswith("/v9/custom-chat"), seen["path"]
+
+
+@pytest.mark.parametrize("headers", [
+ pytest.param(_CF_EDGE, id="cloudflare"),
+ pytest.param({}, id="no-signal"),
+ pytest.param({"server": "APISIX"}, id="apisix"),
+ pytest.param({"cf-ray": "8f2b1c4d5e6a7890-SJC", "server": "nginx"}, id="relayed-cf-ray"),
+])
+@pytest.mark.parametrize("code", [521, 522, 523])
+def test_52x_is_retried_whatever_the_edge_looks_like(monkeypatch, code, headers):
+ """判据只看码:``AI_BASE_URL`` 后面挂哪家网关不可知,靠响应头认 Cloudflare 会把真实
+ 链路上的 52x 全判否(实测网关自报 ``server: APISIX``),整条重试等于不存在。
+ """
+ calls = {"n": 0}
+
+ def h(request):
+ calls["n"] += 1
+ if calls["n"] <= 2:
+ return httpx.Response(code, headers=headers, text="Connection timed out")
+ return httpx.Response(200, json=_img_payload(_big_b64()))
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+ assert _image_provider(h).gen_image("x", []).startswith(b"\x89PNG")
+ assert calls["n"] == 3, "必须真的重发,而不是靠外层出图循环碰运气"
+
+
+@pytest.mark.parametrize("code", [520, 524])
+def test_ambiguous_52x_is_never_retried(code):
+ """520 / 524 连接已建立、请求可能正在源站处理中,重发就是为同一张图付两次钱。"""
+ seen = {"n": 0}
+
+ def h(request):
+ seen["n"] += 1
+ return httpx.Response(code, headers={"server": "cloudflare"}, text="ambiguous")
+
+ with pytest.raises(httpx.HTTPStatusError):
+ _image_provider(h).gen_image("x", [])
+ assert seen["n"] == 1, f"HTTP {code} 被重试了,会重复计费"
+
+
+def test_retryable_set_excludes_the_codes_that_may_have_billed():
+ """常量本身也钉一道:改集合的人不必先读懂 _post 才发现自己开了重复计费的洞。"""
+ from windup_framework.providers.sufy import _CLOUDFLARE_UNREACHED_STATUS
+
+ assert _CLOUDFLARE_UNREACHED_STATUS == {521, 522, 523}
+
+
+def test_unreached_resends_are_capped_across_the_whole_gen_image(monkeypatch):
+ """52x 的"没到上游"是大概率不是保证(CF 的 522 含"连上了但源站没及时确认"),
+ 所以可重复计费的重发次数按整次 gen_image 封顶,不跟着内外两层循环叠乘。
+ """
+ from windup_framework.providers.sufy import _UNREACHED_RESENDS
+
+ calls = {"n": 0}
+
+ def h(request):
+ calls["n"] += 1
+ if calls["n"] % 2:
+ return httpx.Response(522, headers={"server": "APISIX"}, text="timed out")
+ return httpx.Response(200, json={"choices": [{"message": {"content": "无图"}}]})
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ with pytest.raises(RuntimeError, match=r"已重发 2 次"):
+ _image_provider(h).gen_image("x", [])
+ # 预算若按 _post 调用各算一份,外层三轮就会重发 3 次而不是 2 次。
+ assert calls["n"] == 2 * _UNREACHED_RESENDS + 1
+
+
+def test_exhausted_retries_report_the_edge_fingerprint(monkeypatch):
+ """三次全 52x 正是最需要复盘的场景,而它唯一留下的就是这条异常文本。"""
+ def h(request):
+ return httpx.Response(522, headers={"server": "APISIX", "cf-ray": "8f2b-SJC"})
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ with pytest.raises(RuntimeError, match=r"522.*已重发 2 次.*server=APISIX"):
+ _image_provider(h).gen_image("x", [])
+
+
+def test_rate_limit_exhaustion_also_reports_the_fingerprint(monkeypatch):
+ """限流与"网关连不上上游"要能一眼分开 —— 两者的处置完全不同。"""
+ from windup_framework.providers.sufy import _POST_TRIES
+
+ calls = {"n": 0}
+
+ def h(request):
+ calls["n"] += 1
+ return httpx.Response(429, headers={"server": "APISIX"}, text="slow down")
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None)
+
+ with pytest.raises(RuntimeError, match=r"过于频繁.*连发 3 次.*server=APISIX"):
+ _image_provider(h).gen_image("x", [])
+ assert calls["n"] == _POST_TRIES
+
+
+def test_unreached_backoff_is_capped(monkeypatch):
+ """上游挂掉时不该把一个图像任务堵成长时间阻塞。"""
+ from windup_framework.providers.sufy import _MAX_RETRY_WAIT
+
+ sleeps: list[float] = []
+
+ def h(request):
+ return httpx.Response(522, headers={**_CF_EDGE, "Retry-After": "9999"})
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", sleeps.append)
+
+ with pytest.raises(RuntimeError, match="522"):
+ _image_provider(h).gen_image("x", [])
+ assert sleeps and max(sleeps) <= _MAX_RETRY_WAIT
+
+
+def test_worst_case_request_count_and_wait_are_bounded(monkeypatch):
+ """内外两层重试会叠乘,最坏情况必须是个说得出的数,而不是"看情况"。"""
+ from windup_framework.providers.sufy import _IMAGE_TRIES, _MAX_RETRY_WAIT, _POST_TRIES
+
+ calls = {"n": 0}
+ sleeps: list[float] = []
+
+ def h(request):
+ calls["n"] += 1
+ if calls["n"] % _POST_TRIES:
+ return httpx.Response(429, text="slow down")
+ return httpx.Response(200, json={"choices": [{"message": {"content": "无图"}}]})
+
+ monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", sleeps.append)
+
+ with pytest.raises(RuntimeError, match="均未取得有效图"):
+ _image_provider(h).gen_image("x", [])
+ assert calls["n"] == _IMAGE_TRIES * _POST_TRIES == 9
+ assert sum(sleeps) <= _IMAGE_TRIES * (_POST_TRIES - 1) * _MAX_RETRY_WAIT
+
+
+@pytest.mark.parametrize("code", [400, 404])
+def test_model_missing_from_the_gateway_catalogue_says_so(code):
+ """同一把 key 下不同网关的模型目录不一样(实测:一个 73 个模型零图像模型、
+ 另一个 134 个含默认模型)。配错 AI_BASE_URL 时错误必须指向配置,不能只是裸 404。
+ """
+ def h(request):
+ import httpx
+ return httpx.Response(code, text='{"error":{"message":"model not found"}}')
+
+ with pytest.raises(RuntimeError, match=r"/models"):
+ _image_provider(h).gen_image("x", [])
+
+
+# ── 模型型号可配置(2026-08-11 人工评审:providers 层硬编码太多)───────────────
+
+
+def _cfg(**kw):
+ from windup_framework.config.provider import AIProviderSettings
+
+ return AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k", **kw)
+
+
+@pytest.mark.parametrize(("cls_name", "field", "value"), [
+ ("SufyVideoProvider", "video_model", "kling-v9-test"),
+ ("SufyImageProvider", "image_model", "gemini-9-flash-image"),
+])
+def test_each_provider_reads_its_own_model_field(cls_name, field, value):
+ """三条能力同时在用不同模型,所以是三个独立字段而不是共用一个 ``model``。
+
+ 共用一个的后果是换其中一条把另外两条也换了 —— 这条用例把"各读各的"钉住:
+ 只设自己那个字段,另外两个保持默认,断言取到的是自己的。
+ """
+ import windup_framework.providers.sufy as S
+
+ cls = getattr(S, cls_name)
+ assert cls(config=_cfg(**{field: value}))._model == value
+
+
+def test_explicit_model_argument_still_wins_over_config():
+ """显式传参优先于配置 —— A/B 对比时不必改环境变量。"""
+ from windup_framework.providers.sufy import SufyImageProvider
+
+ p = SufyImageProvider(config=_cfg(image_model="from-config"), model="from-arg")
+ assert p._model == "from-arg"
+
+
+def test_request_shape_is_not_configurable():
+ """**只有型号可配,请求形状不可配。**
+
+ (FAL 队列面已随「从未真实调用过」一并移除,故这里只剩两个型号字段。)
+
+ 哪个模型吃 image_list、FAL 队列路径长什么样,是该模型的 API 事实而非运行参数。
+ 放进配置会把"填错了会怎样"从部署期推到运行期:字段塞错不会立刻报错,任务照常
+ queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。
+
+ 故断言配置类**没有**这类字段 —— 将来有人想加会先撞到这条用例和它的理由。
+ """
+ from windup_framework.config.provider import AIProviderSettings
+
+ fields = set(AIProviderSettings.model_fields)
+ for banned in ("image_list_models", "fal_endpoints", "first_frame_field"):
+ assert banned not in fields, f"{banned} 不该进配置,见本用例 docstring"
+ assert {"video_model", "image_model"} <= fields
+
+
+# ── i2v 主流程(付费路径,此前零覆盖)─────────────────────────────────────────
+
+
+def _jpeg_first_frame(w: int = 200, h: int = 300) -> bytes:
+ """一张竖长的图,用来验首帧被按目标画布补边而不是拉伸。"""
+ import io as _io
+
+ from PIL import Image as _Image
+
+ buf = _io.BytesIO()
+ _Image.new("RGB", (w, h), (40, 80, 160)).save(buf, "PNG")
+ return buf.getvalue()
+
+
+@pytest.fixture(autouse=True)
+def _no_sleep(monkeypatch):
+ """轮询里的 time.sleep 打桩 —— 用例不该真等。"""
+ import windup_framework.providers.sufy as _S
+
+ monkeypatch.setattr(_S.time, "sleep", lambda *_: None)
+
+
+def _video_provider(handler, **kw):
+ import httpx as _httpx
+
+ from windup_framework.config.provider import AIProviderSettings
+ from windup_framework.providers.sufy import SufyVideoProvider
+
+ p = SufyVideoProvider(
+ config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k"),
+ # 轮询预算 = max_min * 60 // poll。poll 取大值让预算只有几次,
+ # 再把 time.sleep 打桩掉,用例就既快又不空转(第一版 poll=0.001 配
+ # max_min=1 会真轮询 6 万次,单文件跑了 96 秒)。
+ poll_interval=30.0,
+ **kw,
+ )
+ client = _httpx.Client(
+ base_url="https://gw.example.com/v1",
+ headers={"Authorization": "Bearer k"},
+ transport=_httpx.MockTransport(handler),
+ )
+ p._client = lambda: client
+ return p
+
+
+def _i2v_handler(seen: dict, *, statuses=("completed",), video=b"MP4DATA" * 200):
+ """提交 → 轮询 → 下载 三段式的假网关。"""
+ import httpx as _httpx
+
+ calls = {"n": 0}
+
+ def h(request):
+ path = request.url.path
+ if request.method == "POST" and path.endswith("/videos"):
+ seen["body"] = json.loads(request.content)
+ return _httpx.Response(200, json={"id": "job-1"})
+ if request.method == "GET" and "/videos/" in path:
+ i = min(calls["n"], len(statuses) - 1)
+ calls["n"] += 1
+ st = statuses[i]
+ if st == "completed":
+ return _httpx.Response(200, json={
+ "status": "completed",
+ "task_result": {"videos": [{"url": "https://gw.example.com/out.mp4"}]},
+ })
+ return _httpx.Response(200, json={"status": st, "error": "boom"})
+ seen["download_headers"] = dict(request.headers)
+ return _httpx.Response(200, content=video,
+ headers={"Content-Length": str(len(video))})
+
+ return h
+
+
+def test_i2v_submits_polls_and_downloads():
+ """一条完整的付费路径:提交拿 job id → 轮询到 completed → 下载 mp4。"""
+ seen: dict = {}
+ data = _video_provider(_i2v_handler(seen)).i2v(_jpeg_first_frame(), "walk right")
+ assert data.startswith(b"MP4DATA")
+ body = seen["body"]
+ assert body["prompt"] == "walk right"
+ assert body["seconds"] == "5" and isinstance(body["seconds"], str), "seconds 必须是字符串"
+ assert body["mode"] == "std"
+
+
+def test_first_frame_goes_as_a_jpeg_data_uri():
+ """PNG base64 会让任务 status=failed(VENDOR_FAILED,2026-07-22 实测,33s fail-fast)。
+ 首帧必须转 JPEG —— 这条错在提交后才报,本地看不出来。
+ """
+ seen: dict = {}
+ _video_provider(_i2v_handler(seen)).i2v(_jpeg_first_frame(), "x")
+ uri = seen["body"]["input_reference"]
+ assert uri.startswith("data:image/jpeg;base64,"), uri[:40]
+
+ import base64 as _b64
+ import io as _io
+
+ from PIL import Image as _Image
+
+ im = _Image.open(_io.BytesIO(_b64.b64decode(uri.split(",", 1)[1])))
+ assert im.format == "JPEG"
+
+
+def test_first_frame_is_padded_to_the_target_canvas_not_stretched():
+ """按目标画布补边、不拉伸:拉伸会让角色比例变形,而母版比例是角色一致性的一部分。"""
+ import base64 as _b64
+ import io as _io
+
+ from PIL import Image as _Image
+
+ # 源图放一个偏心的亮块:拉伸会把它拉宽,补边会保持它的宽高比。
+ # 只看"对称两点颜色相同"是无效判据 —— 纯色图拉伸后照样相同
+ # (2026-08-11 变异测试逮到第一版正是如此,M3 存活)。
+ buf = _io.BytesIO()
+ src = _Image.new("RGB", (200, 300), (40, 80, 160))
+ src.paste((250, 250, 250), (80, 100, 120, 140)) # 40x40 的方块
+ src.save(buf, "PNG")
+
+ seen: dict = {}
+ _video_provider(_i2v_handler(seen)).i2v(buf.getvalue(), "x", size="1280x720")
+ im = _Image.open(_io.BytesIO(_b64.b64decode(seen["body"]["input_reference"].split(",", 1)[1])))
+ assert im.size == (1280, 720), "首帧应铺满目标画布"
+
+ # 量那个方块在成品里的宽高比。补边:源 40x40 等比缩放后仍是 1:1。
+ # 拉伸:横向被拉 1280/200=6.4 倍、纵向 720/300=2.4 倍,比例变成 ~2.67:1。
+ import numpy as _np
+
+ a = _np.asarray(im.convert("L"))
+ ys, xs = _np.where(a > 200)
+ ratio = (xs.max() - xs.min() + 1) / (ys.max() - ys.min() + 1)
+ assert 0.8 < ratio < 1.25, f"方块宽高比 {ratio:.2f},说明被拉伸了(补边应≈1.0)"
+
+
+@pytest.mark.parametrize("bad", ["failed", "cancelled"])
+def test_terminal_failure_raises_instead_of_polling_to_timeout(bad):
+ """网关报 failed/cancelled 要立刻抛,别把剩下的轮询次数耗完 —— 钱已经花了,
+ 尽快把原因暴露给上层比多等几分钟有用。
+ """
+ with pytest.raises(RuntimeError, match=bad):
+ _video_provider(_i2v_handler({}, statuses=(bad,))).i2v(_jpeg_first_frame(), "x")
+
+
+def test_never_completing_job_raises_after_the_poll_budget():
+ """轮询预算用尽仍未 completed → 抛错,不返回空 bytes。
+
+ 返回空 bytes 的话上游会把它当成一段视频送进抽帧,报"视频无可解码帧",
+ 真正的原因(超时)就被埋掉了。
+ """
+ p = _video_provider(_i2v_handler({}, statuses=("in_progress",)), max_min=1) # 预算 2 次
+ with pytest.raises(RuntimeError, match="未取得视频 URL"):
+ p.i2v(_jpeg_first_frame(), "x")
+
+
+def test_image_list_models_use_a_different_first_frame_field():
+ """字段按模型选。塞错字段不会立刻报错 —— 任务 status=queued 正常返回,
+ 直到生成阶段才 failed "model is not supported",而费用可能已经产生
+ (2026-07-29 实测)。
+ """
+ from windup_framework.providers.sufy import _IMAGE_LIST_MODELS
+
+ seen: dict = {}
+ p = _video_provider(_i2v_handler(seen), model=_IMAGE_LIST_MODELS[0], mode="pro")
+ p.i2v(_jpeg_first_frame(), "x")
+ assert "image_list" in seen["body"] and "input_reference" not in seen["body"]
+ assert not seen["body"]["image_list"][0]["image"].startswith("data:"), \
+ "image_list 要裸 base64,不带 data URI 前缀"
+
+
+def test_non_positive_poll_interval_is_rejected_at_construction():
+ """轮询间隔 <= 0 在构造时就拒。
+
+ 此前会活到 i2v 里 `max_min * 60 // poll` 那一步除零 —— 报 ZeroDivisionError,
+ 读的人完全看不出是配错了参数(2026-08-11 补 i2v 主流程测试时逮到)。
+ 0 的语义本身也不成立:那是忙等,会把网关打满。
+ """
+ from windup_framework.config.provider import AIProviderSettings
+ from windup_framework.providers.sufy import SufyVideoProvider
+
+ cfg = AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k")
+ for bad in (0, -1, -0.5):
+ with pytest.raises(ValueError, match="poll_interval"):
+ SufyVideoProvider(config=cfg, poll_interval=bad)
diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py
index e260609b..31cc1761 100644
--- a/backend/tests/test_user_service.py
+++ b/backend/tests/test_user_service.py
@@ -31,6 +31,12 @@
# -- Fixtures ------------------------------------------------------------
+@pytest.fixture(autouse=True)
+def _seed_invite(request):
+ if "db_session" in request.fixturenames:
+ request.getfixturevalue("invite_code")
+
+
@pytest.fixture()
def mock_redis():
"""Mock Redis 客户端。"""
@@ -38,6 +44,7 @@ def mock_redis():
redis_mock.get.return_value = None
redis_mock.setex.return_value = True
redis_mock.delete.return_value = True
+ redis_mock.eval.return_value = None # Lua 脚本默认返回 None
redis_mock.pipeline.return_value = MagicMock(
execute=MagicMock(return_value=[True, True])
)
@@ -128,9 +135,10 @@ def test_register_success(db_session, service, mock_email):
email="new@example.com",
password="password123",
code="123456",
+ invite_code="AB23CD45",
)
- result = service.register_by_email_with_session(db_session, input_data)
+ result = service.register_by_email(db_session, input_data)
assert result.user.email == "new@example.com"
assert result.access_token is not None
@@ -138,15 +146,103 @@ def test_register_success(db_session, service, mock_email):
assert result.user.email_verified_at is not None # 注册即验证
+def test_public_methods_accept_session(db_session, service, mock_email):
+ """公开接口走 session-per-call,不再抛 NotImplementedError。"""
+ service._redis.get.return_value = "123456"
+ registered = service.register_by_email(
+ db_session,
+ RegisterInput(
+ email="public@example.com",
+ password="password123",
+ code="123456",
+ invite_code="AB23CD45",
+ ),
+ )
+
+ by_id = service.get_by_id(db_session, registered.user.id)
+ by_email = service.get_by_email(db_session, "public@example.com")
+ assert by_id is not None
+ assert by_email is not None
+ assert by_id.email == by_email.email == "public@example.com"
+
+ service._redis.get.return_value = None # 未锁定
+ login = service.login_by_password(
+ db_session,
+ LoginByPasswordInput(email="public@example.com", password="password123"),
+ )
+ assert login.user.id == registered.user.id
+
+ service.change_password(
+ db_session,
+ registered.user.id,
+ ChangePasswordInput(old_password="password123", new_password="newpass123"),
+ )
+
+ service._redis.get.return_value = "654321"
+ code_login = service.login_by_code(
+ db_session,
+ LoginByCodeInput(email="public@example.com", code="654321"),
+ )
+ assert code_login.user.id == registered.user.id
+
+
+def test_register_creates_credit_account(db_session, service, mock_email):
+ """注册时应自动创建积分账户并赠送初始积分。"""
+ from sqlalchemy import select
+ from windup_app.server.quota.model import CreditAccount, CreditTransaction
+ from windup_common.enums.quota import CreditReason
+ from windup_framework.config.quota import settings as quota_settings
+
+ service._redis.get.return_value = "123456"
+
+ input_data = RegisterInput(
+ email="credit@example.com",
+ password="password123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+
+ result = service.register_by_email(db_session, input_data)
+ user_id = result.user.id
+ expected = quota_settings.register_gift_amount + quota_settings.invite_reward_amount
+
+ # 验证积分账户已创建
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == user_id)
+ )
+ assert account is not None
+ assert account.balance == expected
+ assert account.frozen == 0
+ assert account.total_earned == expected
+ assert account.total_spent == 0
+
+ # 验证赠送流水已记录
+ txn = db_session.scalar(
+ select(CreditTransaction).where(
+ CreditTransaction.user_id == user_id,
+ CreditTransaction.reason == CreditReason.REGISTER_GIFT,
+ )
+ )
+ assert txn is not None
+ assert txn.delta == quota_settings.register_gift_amount
+ assert txn.ref_id == f"register:{user_id}"
+ assert txn.balance_after == quota_settings.register_gift_amount
+
+
def test_register_duplicate_email(db_session, service):
# 先注册一个用户
service._redis.get.return_value = "123456"
- input_data = RegisterInput(email="dup@example.com", password="pass123", code="123456")
- service.register_by_email_with_session(db_session, input_data)
+ input_data = RegisterInput(
+ email="dup@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, input_data)
# 尝试重复注册
with pytest.raises(BizException, match="邮箱已注册"):
- service.register_by_email_with_session(db_session, input_data)
+ service.register_by_email(db_session, input_data)
def test_register_wrong_code(db_session, service):
@@ -156,10 +252,11 @@ def test_register_wrong_code(db_session, service):
email="new@example.com",
password="password123",
code="999999", # 错误验证码
+ invite_code="AB23CD45",
)
with pytest.raises(BizException, match="验证码错误"):
- service.register_by_email_with_session(db_session, input_data)
+ service.register_by_email(db_session, input_data)
def test_register_expired_code(db_session, service):
@@ -169,10 +266,70 @@ def test_register_expired_code(db_session, service):
email="new@example.com",
password="password123",
code="123456",
+ invite_code="AB23CD45",
)
with pytest.raises(BizException, match="验证码已过期"):
- service.register_by_email_with_session(db_session, input_data)
+ service.register_by_email(db_session, input_data)
+
+
+def test_register_blank_invite_code_only_gives_register_gift(db_session, service):
+ """未带邀请码时只发注册赠送,不挡注册。"""
+ from sqlalchemy import select
+ from windup_app.server.quota.model import CreditAccount
+ from windup_framework.config.quota import settings as quota_settings
+
+ service._redis.get.return_value = "123456"
+ input_data = RegisterInput(
+ email="blank-invite@example.com",
+ password="password123",
+ code="123456",
+ invite_code=" ",
+ )
+
+ result = service.register_by_email(db_session, input_data)
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == result.user.id)
+ )
+ assert account is not None
+ assert account.balance == quota_settings.register_gift_amount
+
+
+def test_register_rejects_invite_code_outside_link_charset(db_session, service):
+ """前端邀请链接用 A-H/J-N/P-Z/2-9,含 I/O/0/1 的码不会进注册请求。"""
+ service._redis.get.return_value = "123456"
+ input_data = RegisterInput(
+ email="bad-charset@example.com",
+ password="password123",
+ code="123456",
+ invite_code="IIII",
+ )
+
+ with pytest.raises(BizException, match="邀请码无效"):
+ service.register_by_email(db_session, input_data)
+
+
+def test_register_expired_invite_code(db_session, service):
+ from datetime import datetime, timedelta, timezone
+ from sqlalchemy import select
+ from windup_app.server.quota.model import InviteCode
+ from windup_common.enums.biz_code import BizCode
+
+ row = db_session.scalar(select(InviteCode).where(InviteCode.code == "AB23CD45"))
+ row.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
+ db_session.flush()
+
+ service._redis.get.return_value = "123456"
+ input_data = RegisterInput(
+ email="late@example.com",
+ password="password123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ with pytest.raises(BizException, match="邀请码已过期") as exc:
+ service.register_by_email(db_session, input_data)
+ assert exc.value.code == BizCode.NOT_FOUND
+ assert db_session.scalar(select(User).where(User.email == "late@example.com")) is None
# -- 登录测试 ------------------------------------------------------------
@@ -181,13 +338,18 @@ def test_register_expired_code(db_session, service):
def test_login_success(db_session, service, mock_email):
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="login@example.com", password="pass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="login@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 登录(不需要验证码)
service._redis.get.return_value = None # 未锁定
login_input = LoginByPasswordInput(email="login@example.com", password="pass123")
- result = service.login_by_password_with_session(db_session, login_input)
+ result = service.login_by_password(db_session, login_input)
assert result.user.email == "login@example.com"
assert result.access_token is not None
@@ -196,8 +358,13 @@ def test_login_success(db_session, service, mock_email):
def test_login_wrong_password(db_session, service, mock_email):
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="login@example.com", password="pass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="login@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 密码错误
service._redis.get.return_value = None # 未锁定
@@ -205,7 +372,7 @@ def test_login_wrong_password(db_session, service, mock_email):
login_input = LoginByPasswordInput(email="login@example.com", password="wrong")
with pytest.raises(BizException, match="邮箱或密码错误"):
- service.login_by_password_with_session(db_session, login_input)
+ service.login_by_password(db_session, login_input)
def test_login_nonexistent_user(db_session, service):
@@ -214,17 +381,23 @@ def test_login_nonexistent_user(db_session, service):
login_input = LoginByPasswordInput(email="no@example.com", password="pass123")
with pytest.raises(BizException, match="邮箱或密码错误"):
- service.login_by_password_with_session(db_session, login_input)
+ service.login_by_password(db_session, login_input)
def test_login_banned_user(db_session, service, mock_email):
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="banned@example.com", password="pass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="banned@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 封禁用户
from sqlalchemy import select
+
user = db_session.scalar(select(User).where(User.email == "banned@example.com"))
user.status = UserStatus.BANNED
db_session.flush()
@@ -234,32 +407,101 @@ def test_login_banned_user(db_session, service, mock_email):
login_input = LoginByPasswordInput(email="banned@example.com", password="pass123")
with pytest.raises(BizException, match="账号已被封禁"):
- service.login_by_password_with_session(db_session, login_input)
+ service.login_by_password(db_session, login_input)
# -- 验证码登录测试 ------------------------------------------------------
-def test_login_by_code_new_user(db_session, service, mock_email):
- service._redis.get.return_value = "123456"
+def test_login_by_code_unknown_email_creates_user_and_gifts(
+ db_session, service, mock_email
+):
+ """未知邮箱验证码登录自动建号,并只发注册赠送。"""
+ from sqlalchemy import select
+ from windup_app.server.quota.model import CreditAccount
+ from windup_framework.config.quota import settings as quota_settings
+ service._redis.get.return_value = "123456"
input_data = LoginByCodeInput(email="code@example.com", code="123456")
- result = service.login_by_code_with_session(db_session, input_data)
- assert result.user.email == "code@example.com"
+ result = service.login_by_code(db_session, input_data)
+
+ user = db_session.scalar(select(User).where(User.email == "code@example.com"))
+ assert user is not None
+ assert result.user.id == user.id
+ assert result.user.email_verified_at is not None
+ account = db_session.scalar(
+ select(CreditAccount).where(CreditAccount.user_id == user.id)
+ )
+ assert account is not None
+ assert account.balance == quota_settings.register_gift_amount
+
+
+def test_send_verification_code_allows_register_purpose(service, mock_email):
+ service._redis.get.return_value = None
+
+ service.send_verification_code("new@example.com", "register")
+
+ mock_email.send_verification_code.assert_called_once()
+
+
+def test_login_by_code_banned_user(db_session, service, mock_email):
+ from sqlalchemy import select
+
+ service._redis.get.return_value = "123456"
+ service.register_by_email(
+ db_session,
+ RegisterInput(
+ email="banned-code@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ ),
+ )
+ user = db_session.scalar(
+ select(User).where(User.email == "banned-code@example.com")
+ )
+ user.status = UserStatus.BANNED
+ db_session.flush()
+
+ service._redis.get.return_value = "654321"
+ with pytest.raises(BizException, match="账号已被封禁"):
+ service.login_by_code(
+ db_session,
+ LoginByCodeInput(email="banned-code@example.com", code="654321"),
+ )
+
+
+def test_login_by_code_marks_unverified_email(db_session, service):
+ user = User(email="unverified@example.com", password_hash=_hash_password("pass123"))
+ db_session.add(user)
+ db_session.flush()
+
+ service._redis.get.return_value = "123456"
+ result = service.login_by_code(
+ db_session,
+ LoginByCodeInput(email="unverified@example.com", code="123456"),
+ )
+
+ assert result.user.email == "unverified@example.com"
assert result.user.email_verified_at is not None
def test_login_by_code_existing_user(db_session, service, mock_email):
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="exist@example.com", password="pass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="exist@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 验证码登录
service._redis.get.return_value = "654321"
input_data = LoginByCodeInput(email="exist@example.com", code="654321")
- result = service.login_by_code_with_session(db_session, input_data)
+ result = service.login_by_code(db_session, input_data)
assert result.user.email == "exist@example.com"
@@ -270,7 +512,7 @@ def test_login_by_code_wrong_code(db_session, service):
input_data = LoginByCodeInput(email="code@example.com", code="999999")
with pytest.raises(BizException, match="验证码错误"):
- service.login_by_code_with_session(db_session, input_data)
+ service.login_by_code(db_session, input_data)
# -- 发送验证码测试 ------------------------------------------------------
@@ -316,22 +558,39 @@ def test_refresh_tokens(service, mock_redis):
# 先创建一个 refresh token
token, jti = create_refresh_token(1, "test@example.com")
- # Mock Redis 返回 user_id
- mock_redis.get.return_value = "1"
+ # Mock Redis eval 返回 user_id(Lua 脚本成功)
+ mock_redis.eval.return_value = "1"
result = service.refresh_tokens(token)
assert result.access_token is not None
assert result.refresh_token is not None
assert result.user.id == 1
+ # 验证调用了 eval(Lua 脚本),而不是 get
+ mock_redis.eval.assert_called_once()
def test_refresh_tokens_revoked(service, mock_redis):
token, jti = create_refresh_token(1, "test@example.com")
- # Mock Redis 返回 None(已撤销)
- mock_redis.get.return_value = None
+ # Mock Redis eval 返回 None(Lua 脚本:旧 token 不存在)
+ mock_redis.eval.return_value = None
+
+ with pytest.raises(BizException, match="refresh token 已失效"):
+ service.refresh_tokens(token)
+
+
+def test_refresh_tokens_concurrent_reuse(service, mock_redis):
+ """并发重放:同一个 refresh token 第二次调用应失败。"""
+ token, jti = create_refresh_token(1, "test@example.com")
+
+ # 第一次调用成功
+ mock_redis.eval.return_value = "1"
+ result1 = service.refresh_tokens(token)
+ assert result1.access_token is not None
+ # 第二次调用(并发重放)失败
+ mock_redis.eval.return_value = None
with pytest.raises(BizException, match="refresh token 已失效"):
service.refresh_tokens(token)
@@ -342,17 +601,26 @@ def test_refresh_tokens_revoked(service, mock_redis):
def test_change_password(db_session, service, mock_email):
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="change@example.com", password="oldpass123", code="123456")
- result = service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="change@example.com",
+ password="oldpass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ result = service.register_by_email(db_session, register_input)
# 修改密码
- change_input = ChangePasswordInput(old_password="oldpass123", new_password="newpass123")
- service.change_password_with_session(db_session, result.user.id, change_input)
+ change_input = ChangePasswordInput(
+ old_password="oldpass123", new_password="newpass123"
+ )
+ service.change_password(db_session, result.user.id, change_input)
# 用新密码登录
service._redis.get.return_value = None
- login_input = LoginByPasswordInput(email="change@example.com", password="newpass123")
- login_result = service.login_by_password_with_session(db_session, login_input)
+ login_input = LoginByPasswordInput(
+ email="change@example.com", password="newpass123"
+ )
+ login_result = service.login_by_password(db_session, login_input)
assert login_result.user.email == "change@example.com"
@@ -360,14 +628,19 @@ def test_change_password(db_session, service, mock_email):
def test_change_password_wrong_old(db_session, service, mock_email):
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="change@example.com", password="oldpass123", code="123456")
- result = service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="change@example.com",
+ password="oldpass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ result = service.register_by_email(db_session, register_input)
# 旧密码错误
change_input = ChangePasswordInput(old_password="wrong", new_password="newpass123")
with pytest.raises(BizException, match="旧密码错误"):
- service.change_password_with_session(db_session, result.user.id, change_input)
+ service.change_password(db_session, result.user.id, change_input)
# -- 昵称修改测试 --------------------------------------------------------
@@ -376,11 +649,17 @@ def test_change_password_wrong_old(db_session, service, mock_email):
def test_update_nickname(db_session, service, mock_email):
"""修改昵称后立即生效。"""
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="nick@example.com", password="pass1234", code="123456", nickname="旧昵称")
- result = service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="nick@example.com",
+ password="pass1234",
+ code="123456",
+ nickname="旧昵称",
+ invite_code="AB23CD45",
+ )
+ result = service.register_by_email(db_session, register_input)
update_input = UpdateNicknameInput(nickname="新昵称")
- user_view = service.update_nickname_with_session(db_session, result.user.id, update_input)
+ user_view = service.update_nickname(db_session, result.user.id, update_input)
assert user_view.nickname == "新昵称"
assert user_view.id == result.user.id
@@ -389,12 +668,17 @@ def test_update_nickname(db_session, service, mock_email):
def test_update_nickname_max_length(db_session, service, mock_email):
"""昵称长度上限 50。"""
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="nick2@example.com", password="pass1234", code="123456")
- result = service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="nick2@example.com",
+ password="pass1234",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ result = service.register_by_email(db_session, register_input)
long_nickname = "a" * 50
update_input = UpdateNicknameInput(nickname=long_nickname)
- user_view = service.update_nickname_with_session(db_session, result.user.id, update_input)
+ user_view = service.update_nickname(db_session, result.user.id, update_input)
assert user_view.nickname == long_nickname
@@ -404,7 +688,7 @@ def test_update_nickname_user_not_found(db_session, service):
update_input = UpdateNicknameInput(nickname="test")
with pytest.raises(BizException, match="用户不存在"):
- service.update_nickname_with_session(db_session, 999999, update_input)
+ service.update_nickname(db_session, 999999, update_input)
# -- 重置密码测试 --------------------------------------------------------
@@ -414,18 +698,25 @@ def test_reset_password(db_session, service, mock_email):
"""邮箱+验证码重置密码后,新密码可登录。"""
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="reset@example.com", password="oldpass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="reset@example.com",
+ password="oldpass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 重置密码(验证码 purpose 为 reset_password)
service._redis.get.return_value = "654321"
- reset_input = ResetPasswordInput(email="reset@example.com", code="654321", new_password="newpass123")
- service.reset_password_with_session(db_session, reset_input)
+ reset_input = ResetPasswordInput(
+ email="reset@example.com", code="654321", new_password="newpass123"
+ )
+ service.reset_password(db_session, reset_input)
# 用新密码登录
service._redis.get.return_value = None
login_input = LoginByPasswordInput(email="reset@example.com", password="newpass123")
- login_result = service.login_by_password_with_session(db_session, login_input)
+ login_result = service.login_by_password(db_session, login_input)
assert login_result.user.email == "reset@example.com"
@@ -433,24 +724,33 @@ def test_reset_password(db_session, service, mock_email):
def test_reset_password_wrong_code(db_session, service, mock_email):
"""验证码错误时拒绝重置。"""
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="reset2@example.com", password="oldpass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="reset2@example.com",
+ password="oldpass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 验证码错误
service._redis.get.return_value = None # 验证码过期
- reset_input = ResetPasswordInput(email="reset2@example.com", code="000000", new_password="newpass123")
+ reset_input = ResetPasswordInput(
+ email="reset2@example.com", code="000000", new_password="newpass123"
+ )
with pytest.raises(BizException, match="验证码已过期"):
- service.reset_password_with_session(db_session, reset_input)
+ service.reset_password(db_session, reset_input)
def test_reset_password_user_not_found(db_session, service):
"""用户不存在时拒绝重置。"""
service._redis.get.return_value = "654321"
- reset_input = ResetPasswordInput(email="noexist@example.com", code="654321", new_password="newpass123")
+ reset_input = ResetPasswordInput(
+ email="noexist@example.com", code="654321", new_password="newpass123"
+ )
with pytest.raises(BizException, match="用户不存在"):
- service.reset_password_with_session(db_session, reset_input)
+ service.reset_password(db_session, reset_input)
# -- 登录限流测试 --------------------------------------------------------
@@ -460,15 +760,20 @@ def test_login_account_locked(db_session, service, mock_email):
"""账号被锁定后拒绝登录(即使密码正确)。"""
# 先注册
service._redis.get.return_value = "123456"
- register_input = RegisterInput(email="lock@example.com", password="pass123", code="123456")
- service.register_by_email_with_session(db_session, register_input)
+ register_input = RegisterInput(
+ email="lock@example.com",
+ password="pass123",
+ code="123456",
+ invite_code="AB23CD45",
+ )
+ service.register_by_email(db_session, register_input)
# 模拟账号锁定
service._redis.get.return_value = "1" # lock key 存在
login_input = LoginByPasswordInput(email="lock@example.com", password="pass123")
with pytest.raises(BizException, match="邮箱或密码错误"):
- service.login_by_password_with_session(db_session, login_input)
+ service.login_by_password(db_session, login_input)
def test_login_failure_records_count(service, mock_redis):
diff --git a/backend/tests/test_validate_settings.py b/backend/tests/test_validate_settings.py
new file mode 100644
index 00000000..7d246114
--- /dev/null
+++ b/backend/tests/test_validate_settings.py
@@ -0,0 +1,144 @@
+"""配置安全校验测试。
+
+直接实例化 Settings 类,覆盖:
+- 缺失必填字段 → ValidationError
+- 空值 → ValidationError
+- 弱值 / 格式错误 → ValidationError
+- 合法值 → 正常通过
+
+注意:测试中通过 ``_env_file=()`` 禁用 .env 文件读取,确保只受环境变量控制。
+"""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from windup_framework.config.database import DatabaseSettings
+from windup_framework.config.jwt import JWTSettings
+
+
+# ── 辅助:禁用 .env 文件的 fixture ────────────────────────────────
+
+@pytest.fixture(autouse=True)
+def _no_env_files(monkeypatch: pytest.MonkeyPatch):
+ """禁止 Settings 从 .env 文件读取,测试只通过环境变量控制。"""
+ monkeypatch.setattr(
+ "windup_framework.config.jwt.JWTSettings.model_config",
+ {**JWTSettings.model_config, "env_file": ()},
+ )
+ monkeypatch.setattr(
+ "windup_framework.config.database.DatabaseSettings.model_config",
+ {**DatabaseSettings.model_config, "env_file": ()},
+ )
+
+
+# ── JWT_SECRET ────────────────────────────────────────────────────
+
+class TestJWTSettings:
+ """``JWTSettings`` 校验:必填 + 最小长度 32 字符。"""
+
+ def test_missing_secret_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """JWT_SECRET 未设置 → ValidationError。"""
+ monkeypatch.delenv("JWT_SECRET", raising=False)
+ with pytest.raises(ValidationError, match="secret"):
+ JWTSettings()
+
+ def test_empty_secret_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """JWT_SECRET 为空字符串 → ValidationError。"""
+ monkeypatch.setenv("JWT_SECRET", "")
+ with pytest.raises(ValidationError, match="secret"):
+ JWTSettings()
+
+ def test_short_secret_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """JWT_SECRET 不足 32 字符 → ValidationError。"""
+ monkeypatch.setenv("JWT_SECRET", "short-key")
+ with pytest.raises(ValidationError, match="32"):
+ JWTSettings()
+
+ def test_exactly_31_chars_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """JWT_SECRET 恰好 31 字符 → 不满足 ≥32,拒绝。"""
+ monkeypatch.setenv("JWT_SECRET", "a" * 31)
+ with pytest.raises(ValidationError, match="32"):
+ JWTSettings()
+
+ def test_exactly_32_chars_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """JWT_SECRET 恰好 32 字符 → 边界通过。"""
+ monkeypatch.setenv("JWT_SECRET", "b" * 32)
+ s = JWTSettings()
+ assert s.secret.get_secret_value() == "b" * 32
+
+ def test_valid_secret_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """JWT_SECRET 足够长且随机 → 正常构造。"""
+ monkeypatch.setenv("JWT_SECRET", "a" * 64)
+ s = JWTSettings()
+ assert len(s.secret.get_secret_value()) == 64
+
+
+# ── POSTGRES_PASSWORD ────────────────────────────────────────────
+
+class TestDatabaseSettings:
+ """``DatabaseSettings`` 校验:必填 + 最小长度 8 字符。"""
+
+ def test_missing_password_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """POSTGRES_PASSWORD 未设置 → ValidationError。"""
+ monkeypatch.delenv("POSTGRES_PASSWORD", raising=False)
+ with pytest.raises(ValidationError, match="password"):
+ DatabaseSettings()
+
+ def test_empty_password_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """POSTGRES_PASSWORD 为空字符串 → ValidationError。"""
+ monkeypatch.setenv("POSTGRES_PASSWORD", "")
+ with pytest.raises(ValidationError, match="password"):
+ DatabaseSettings()
+
+ def test_short_password_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """POSTGRES_PASSWORD 不足 8 字符 → ValidationError。"""
+ monkeypatch.setenv("POSTGRES_PASSWORD", "short")
+ with pytest.raises(ValidationError, match="8"):
+ DatabaseSettings()
+
+ def test_exactly_7_chars_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """POSTGRES_PASSWORD 恰好 7 字符 → 不满足 ≥8,拒绝。"""
+ monkeypatch.setenv("POSTGRES_PASSWORD", "a" * 7)
+ with pytest.raises(ValidationError, match="8"):
+ DatabaseSettings()
+
+ def test_exactly_8_chars_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """POSTGRES_PASSWORD 恰好 8 字符 → 边界通过。"""
+ monkeypatch.setenv("POSTGRES_PASSWORD", "a" * 8)
+ s = DatabaseSettings()
+ assert s.password == "a" * 8
+
+ def test_valid_password_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """POSTGRES_PASSWORD 合法 → 正常构造。"""
+ monkeypatch.setenv("POSTGRES_PASSWORD", "strongpassword123")
+ s = DatabaseSettings()
+ assert s.password == "strongpassword123"
+
+ def test_url_property_works(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """合法密码下,url 属性应正常返回连接串。"""
+ monkeypatch.setenv("POSTGRES_PASSWORD", "my_strong_pw")
+ s = DatabaseSettings()
+ assert "postgresql+psycopg" in s.url
+ assert "my_strong_pw" in s.url
+
+
+# ── 两者同时缺失 ──────────────────────────────────────────────────
+
+class TestBothMissing:
+ """JWT_SECRET 和 POSTGRES_PASSWORD 同时缺失。"""
+
+ def test_both_missing_raises_on_jwt(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """两个都缺 → JWTSettings 先抛 ValidationError(Pydantic fail-fast)。"""
+ monkeypatch.delenv("JWT_SECRET", raising=False)
+ monkeypatch.delenv("POSTGRES_PASSWORD", raising=False)
+ with pytest.raises(ValidationError):
+ JWTSettings()
+
+ def test_both_missing_raises_on_db(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """两个都缺 → DatabaseSettings 同样抛 ValidationError。"""
+ monkeypatch.delenv("JWT_SECRET", raising=False)
+ monkeypatch.delenv("POSTGRES_PASSWORD", raising=False)
+ with pytest.raises(ValidationError):
+ DatabaseSettings()
diff --git a/backend/tests/test_workflow_run_api.py b/backend/tests/test_workflow_run_api.py
index 6546e3a8..684d19f6 100644
--- a/backend/tests/test_workflow_run_api.py
+++ b/backend/tests/test_workflow_run_api.py
@@ -157,7 +157,8 @@ def test_update_nodes(auth_client):
new_nodes = [{"id": "n1", "type": "action"}]
resp = auth_client.patch(
- f"/workflow-runs/{created['id']}", json={"nodes": new_nodes},
+ f"/workflow-runs/{created['id']}",
+ json={"nodes": new_nodes, "version": created["version"]},
)
assert resp.json()["code"] == 200
@@ -172,7 +173,8 @@ def test_update_status(auth_client):
).json()["data"]
resp = auth_client.patch(
- f"/workflow-runs/{created['id']}", json={"status": "soft_deleted"},
+ f"/workflow-runs/{created['id']}",
+ json={"status": "soft_deleted", "version": created["version"]},
)
assert resp.json()["code"] == 200
@@ -186,7 +188,8 @@ def test_update_invalid_status_returns_400(auth_client):
).json()["data"]
resp = auth_client.patch(
- f"/workflow-runs/{created['id']}", json={"status": "bogus"},
+ f"/workflow-runs/{created['id']}",
+ json={"status": "bogus", "version": created["version"]},
)
assert resp.json()["code"] == 400
@@ -200,12 +203,79 @@ def test_update_other_users_run_returns_404(auth_client, auth_client_b):
).json()["data"]
resp = auth_client_b.patch(
- f"/workflow-runs/{created['id']}", json={"nodes": []},
+ f"/workflow-runs/{created['id']}",
+ json={"nodes": [], "version": created["version"]},
)
assert resp.json()["code"] == 404
+def test_update_requires_version(auth_client):
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/workflow-runs", json=_payload(project["id"]),
+ ).json()["data"]
+
+ resp = auth_client.patch(
+ f"/workflow-runs/{created['id']}", json={"nodes": []},
+ )
+
+ assert resp.json()["code"] == 400
+
+
+def test_update_noop_does_not_increment_version(auth_client):
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/workflow-runs", json=_payload(project["id"]),
+ ).json()["data"]
+
+ resp = auth_client.patch(
+ f"/workflow-runs/{created['id']}", json={"version": created["version"]},
+ )
+
+ assert resp.json()["code"] == 200
+ assert resp.json()["data"]["version"] == created["version"]
+
+
+def test_update_noop_stale_version_returns_409(auth_client):
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/workflow-runs", json=_payload(project["id"]),
+ ).json()["data"]
+
+ auth_client.patch(
+ f"/workflow-runs/{created['id']}",
+ json={"nodes": [{"id": "n1"}], "version": created["version"]},
+ )
+
+ resp = auth_client.patch(
+ f"/workflow-runs/{created['id']}", json={"version": created["version"]},
+ )
+
+ assert resp.json()["code"] == 409
+ assert "冲突" in resp.json()["message"]
+
+
+def test_update_stale_version_returns_409(auth_client):
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/workflow-runs", json=_payload(project["id"]),
+ ).json()["data"]
+
+ auth_client.patch(
+ f"/workflow-runs/{created['id']}",
+ json={"nodes": [{"id": "n1"}], "version": created["version"]},
+ )
+
+ resp = auth_client.patch(
+ f"/workflow-runs/{created['id']}",
+ json={"nodes": [{"id": "n2"}], "version": created["version"]},
+ )
+
+ assert resp.json()["code"] == 409
+ assert "冲突" in resp.json()["message"]
+
+
# -- DELETE /workflow-runs/{id} ------------------------------------------------
@@ -227,6 +297,26 @@ def test_delete_success(auth_client):
assert resp.json()["total"] == 0
+def test_patch_after_delete_does_not_restore_run(auth_client):
+ project = _create_project(auth_client)
+ created = auth_client.post(
+ "/workflow-runs", json=_payload(project["id"]),
+ ).json()["data"]
+
+ auth_client.delete(f"/workflow-runs/{created['id']}")
+
+ resp = auth_client.patch(
+ f"/workflow-runs/{created['id']}",
+ json={"nodes": [{"id": "n1"}], "version": created["version"]},
+ )
+
+ assert resp.json()["code"] == 409
+ got = auth_client.get(f"/workflow-runs/{created['id']}").json()["data"]
+ assert got["status"] == "soft_deleted"
+ assert got["nodes"] == []
+ assert got["version"] == created["version"] + 1
+
+
def test_delete_not_found_returns_404(auth_client):
resp = auth_client.delete("/workflow-runs/99999")
diff --git a/backend/uv.lock b/backend/uv.lock
index bfcbe78f..732fcad6 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -49,6 +49,32 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" },
]
+[[package]]
+name = "av"
+version = "18.0.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" },
+]
+
[[package]]
name = "bcrypt"
version = "5.0.0"
@@ -585,6 +611,33 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" },
]
+[[package]]
+name = "imageio"
+version = "2.37.4"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pillow" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6" },
+]
+
+[[package]]
+name = "imageio-ffmpeg"
+version = "0.6.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a" },
+]
+
[[package]]
name = "import-linter"
version = "2.13"
@@ -2065,6 +2118,9 @@ name = "windup-ai-engine"
version = "0.1.0"
source = { editable = "packages/ai_engine" }
dependencies = [
+ { name = "av" },
+ { name = "imageio" },
+ { name = "imageio-ffmpeg" },
{ name = "langchain-core" },
{ name = "langgraph" },
{ name = "numpy" },
@@ -2075,6 +2131,9 @@ dependencies = [
[package.metadata]
requires-dist = [
+ { name = "av", specifier = ">=14.0" },
+ { name = "imageio", specifier = ">=2.36" },
+ { name = "imageio-ffmpeg", specifier = ">=0.6" },
{ name = "langchain-core", specifier = ">=0.3" },
{ name = "langgraph", specifier = ">=0.2" },
{ name = "numpy", specifier = ">=1.26" },
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..e8f04c23
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,18 @@
+coverage:
+ status:
+ project:
+ default:
+ informational: true
+ patch:
+ default:
+ informational: true
+
+comment:
+ layout: "reach,diff,flags,files"
+ flags:
+ backend:
+ paths:
+ - backend/
+ frontend:
+ paths:
+ - frontend/
diff --git a/docs/screenshots/quick-start-continuity-desktop.jpg b/docs/screenshots/quick-start-continuity-desktop.jpg
new file mode 100644
index 00000000..bd395892
Binary files /dev/null and b/docs/screenshots/quick-start-continuity-desktop.jpg differ
diff --git a/docs/screenshots/quick-start-continuity-mobile.jpg b/docs/screenshots/quick-start-continuity-mobile.jpg
new file mode 100644
index 00000000..157bc943
Binary files /dev/null and b/docs/screenshots/quick-start-continuity-mobile.jpg differ
diff --git a/docs/screenshots/workflow-editor-real-runtime-desktop.jpg b/docs/screenshots/workflow-editor-real-runtime-desktop.jpg
new file mode 100644
index 00000000..5eb99096
Binary files /dev/null and b/docs/screenshots/workflow-editor-real-runtime-desktop.jpg differ
diff --git a/docs/screenshots/workflow-editor-real-runtime-mobile.jpg b/docs/screenshots/workflow-editor-real-runtime-mobile.jpg
new file mode 100644
index 00000000..f505d5fd
Binary files /dev/null and b/docs/screenshots/workflow-editor-real-runtime-mobile.jpg differ
diff --git a/docs/superpowers/plans/2026-08-13-first-frame-image-candidates.md b/docs/superpowers/plans/2026-08-13-first-frame-image-candidates.md
new file mode 100644
index 00000000..f802eadb
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-13-first-frame-image-candidates.md
@@ -0,0 +1,109 @@
+# First-frame Image Candidates Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the action first-frame node generate three image candidates from the confirmed character template and action prompt, then let the user select one before complete animation generation.
+
+**Architecture:** Keep the existing six-node WorkflowRun graph unchanged. Map both `character_template` and `first_frame` to the backend `character_image` task, while `complete_animation` remains a `character_action` task. The controller owns workflow transitions; Quick Start and Workflow Editor only present the three candidates and confirm the selected URL.
+
+**Tech Stack:** React 19, TypeScript 6, Vitest, existing Generation HTTP/SSE adapter.
+
+**Spec:** User-approved flow: `角色设定 -> 角色母版 -> 动作首帧三选一 -> 资产生成方式 -> 完整动画 -> 审核`.
+
+## Global Constraints
+
+- Do not change backend code or add a new architecture layer.
+- Keep the six WorkflowRun node types and their dependency edges unchanged.
+- First-frame generation uses one `/generation/image` task with `num_images: 3`.
+- Complete animation generation continues to use `/generation/action` with 32 frames.
+- Use the confirmed character template URL as the sole first-frame reference image.
+- Use the project sprite width and height required by the backend image endpoint.
+
+---
+
+### Task 1: Generation contract and network adapter
+
+**Files:**
+
+- Modify: `frontend/src/entities/generation/index.ts`
+- Modify: `frontend/src/entities/generation/api.ts`
+- Test: `frontend/src/entities/generation/api.test.ts`
+
+**Interfaces:**
+
+- Consumes: confirmed template URL, action prompt, project sprite dimensions.
+- Produces: `FirstFrameGenerationResult { type: 'first_frame'; images: readonly GeneratedImage[] }`.
+
+- [x] Replace the existing first-frame adapter test with a test expecting `/generation/image`, `num_images: 3`, the template URL, prompt, width and height.
+- [x] Run the targeted test and confirm it fails because main still calls `/generation/action`.
+- [x] Change first-frame input/result contracts and map image results according to the supplied frontend expectation.
+- [x] Keep complete animation result validation and `/generation/action` request behavior unchanged.
+- [x] Add GET/SSE coverage proving an expected `first_frame` image task is restored as three candidates.
+- [x] Run `npm test -- src/entities/generation/api.test.ts` and confirm it passes.
+
+### Task 2: WorkflowController first-frame command
+
+**Files:**
+
+- Modify: `frontend/src/features/workflow-controller/controller.ts`
+- Test: `frontend/src/features/workflow-controller/controller.test.ts`
+
+**Interfaces:**
+
+- Consumes: node ID plus project sprite width and height.
+- Produces: one `first_frame` Generation referencing the confirmed character template.
+
+- [x] Add a failing controller test asserting the input contains the confirmed template, action prompt and project dimensions, without video-only fields.
+- [x] Run the targeted test and confirm the old input shape fails it.
+- [x] Introduce a focused first-frame options type and build the image-generation input from the node dependency.
+- [x] Update completed-result validation to accept exactly three first-frame candidate images.
+- [x] Run `npm test -- src/features/workflow-controller/controller.test.ts` and confirm it passes.
+
+### Task 3: Quick Start three-candidate flow
+
+**Files:**
+
+- Modify: `frontend/src/pages/quick-start/service.ts`
+- Modify: `frontend/src/pages/quick-start/service.test.ts`
+- Modify only if required: `frontend/src/pages/quick-start/index.test.tsx`
+
+**Interfaces:**
+
+- Consumes: Project sprite size and `FirstFrameGenerationResult.images`.
+- Produces: three `QuickStartFrame` candidates and confirmation of one selected URL.
+
+- [x] Add failing service tests for forwarding project dimensions and returning all three first-frame candidates.
+- [x] Run the targeted service tests and verify the failures.
+- [x] Carry or resolve project sprite size when opening/starting Quick Start sessions.
+- [x] Map all first-frame result images to the existing candidate selector.
+- [x] Run Quick Start service and page tests.
+
+### Task 4: Workflow Editor three-candidate flow
+
+**Files:**
+
+- Modify: `frontend/src/pages/workflow-editor/index.tsx`
+- Test: `frontend/src/pages/workflow-editor/index.test.tsx`
+
+**Interfaces:**
+
+- Consumes: `WorkflowEditorSession.project.spriteSize` and three generated images.
+- Produces: three selectable candidate buttons and one confirmed `selectedFirstFrameUrl`.
+
+- [x] Add a failing page test asserting that all three first-frame candidates render and one can be confirmed.
+- [x] Run the targeted test and verify it fails with the current single-image rendering.
+- [x] Pass project dimensions to the controller and render the result image array using the existing candidate-card pattern.
+- [x] Run Workflow Editor page tests.
+
+### Task 5: Full verification
+
+**Files:**
+
+- Review all changed files only; do not perform unrelated formatting or refactors.
+
+- [x] Run `npm test`.
+- [x] Run `npm run typecheck`.
+- [x] Run `npm run lint -- --deny-warnings`.
+- [x] Run the formatter on the changed files; the repository-wide check still reports the pre-existing main baseline.
+- [x] Run `npm run build`.
+- [x] Inspect `git diff --check` and the final diff for scope, naming and six-node graph preservation.
diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md
deleted file mode 100644
index 9aa32e8a..00000000
--- a/frontend/API_CONTRACT.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# 前后端接口对齐清单
-
-本实现以尚未合并的后端 PR #75 为目标契约,并要求按 **#75 → 本前端 PR** 的顺序合并。`upstream/main` 当前尚未挂载这些接口。
-
-## 一、本轮已接入
-
-### Project
-
-| 前端方法 | HTTP | 后端能力 |
-|---|---|---|
-| `ProjectApis.list` | `GET /projects` | `page`、`page_size`、可选 `user_id` |
-| `ProjectApis.get` | `GET /projects/{id}` | 项目详情 |
-| `ProjectApis.create` | `POST /projects` | 创建项目记录 |
-| `ProjectApis.remove` | `DELETE /projects/{id}` | 删除项目记录 |
-
-`ProjectOut` 的 `user_id`、`workflow_id`、`project_name`、`character_perspective`、`directional_movement`、精灵宽高、画风、参考图和时间字段,均在 `entities/project` 内显式映射为 camelCase。PR #75 没有项目更新端点,因此前端不声明 `ProjectApis.update`。
-
-项目归属由后端从 access token 取:`ProjectCreate` 不含 `user_id`,`/projects` 各路由统一读 `request.state.current_user.id`,且 `/projects` 不在鉴权白名单里。因此 `CreateProjectInput` 没有 ownerId,请求体也不带 `user_id`——带了等于宣称调用方可以替别人建项目,那正是后端刚修掉的越权口子。`/projects/new` 的创建入口只看有没有 access token(`getApiAccessToken()`);登录模块尚未接入时保持禁用并写明需要登录。
-
-后端枚举按下表映射:
-
-| 后端值 | `character_perspective` | `directional_movement` |
-|---|---|---|
-| `1` | `side` | `single` |
-| `2` | `top-down` | `four-way` |
-| `3` | `isometric` | `eight-way` |
-
-### Character 资产树
-
-| 前端方法 | HTTP | 后端能力 |
-|---|---|---|
-| `CharacterApis.listByProject` | `GET /characters?project_id=...` | 项目内角色分页列表 |
-| `CharacterApis.get` | `GET /characters/{id}` | 角色详情 |
-| `CharacterApis.create` | `POST /characters` | 创建空角色记录 |
-| `CharacterApis.update` | `PATCH /characters/{id}` | 更新角色及完整资产树 |
-| `CharacterApis.remove` | `DELETE /characters/{id}` | 删除角色及其媒体对象 |
-
-后端持久化层级为:
-
-```text
-Character
-└── character_data
- └── outfits[]
- └── actions[]
- └── frames[]
-```
-
-前端只映射后端真实字段:
-
-- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status`
-- Outfit:`id`、`name`、`description`、`preview_url`、`actions`
-- Action:`id`、`type`、`name`、`loop`、`fps`、`frame_count`、`frames`
-- Frame:`index`、`image_url`、`duration_ms`
-
-Outfit、Action、Frame 没有独立端点。`outfit.characterId` 与 `action.outfitId` 仅由嵌套关系推导;修改任一子项时通过 `PATCH Character` 提交完整 `character_data`。
-
-## 二、本轮明确不实现
-
-- Workflow Editor 与生成流程:不在 Projects / 资产库模块内创建弹窗或复制生成逻辑。
-- Action Template:后端没有模块、存储或 HTTP 接口,只保留带原因的禁用入口。
-- 导出:PR #75 没有导出接口;PR #97 是尚未接入资产页的前端打包实现,只保留带原因的禁用入口。
-- 穿戴资产:当前产品定义不向用户暴露独立 Wearable 层级。
-- GIF:Character 契约只提供 Frame 图片 URL;动作卡预览使用排序后的第一帧,不伪造 GIF 字段。
-
-## 三、仍需后端处理
-
-这些问题不由前端降级或伪造数据规避:
-
-1. PR #75 的 `POST /characters` DTO 接收 `name`,但路由没有把 `body.name` 传给 service;前端仍按已声明契约发送 `name`。
-2. Project / Character 路由通过 JWT,但资源查询没有按 `request.state.current_user` 强制归属隔离;前端不能代替后端完成权限边界。
-3. Project 删除没有级联 Character,可能留下孤立角色;数据一致性由后端修复。
-
-## 四、运行前置
-
-- 配置 `VITE_API_BASE_URL`。
-- PR #75 的 Project / Character 路由要求 Bearer access token。Project、Character 实例已统一使用 `getApiAccessToken`;后续登录模块通过 `registerApiAccessTokenProvider` 提供实际 token。token 的取得、保存与刷新不属于本轮,接入前不能把未鉴权请求视为端到端可用。
-- 本模块的生产代码不包含 Mock API 或 livedemo 资产。测试只在 Vitest 中用 HTTP 服务替身验证请求、响应映射与页面行为。
diff --git a/frontend/README.md b/frontend/README.md
index 0a22e3d4..ea57d546 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -47,4 +47,5 @@ Vercel 部署路径不受影响,`vercel.json` 照旧。
运行项目前需要配置 `VITE_API_BASE_URL`。Bearer token 由登录模块取得后,通过 `registerApiAccessTokenProvider` 注册读取函数;业务请求统一从该边界读取。本轮不定义 token 的保存方式。
-与后端尚未对齐的接口见 `API_CONTRACT.md`。
+前后端接口契约以仓库根目录自动生成的 `openapi.json` 为准,也可在本地后端的
+FastAPI `/docs` 页面中查看。
diff --git a/frontend/index.html b/frontend/index.html
index cd1df0cc..c9c17333 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,6 +3,10 @@
+
Windup · 2D 角色资产生成
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 728a09cd..e6d988ea 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,8 @@
"name": "windup-frontend",
"version": "0.0.0",
"dependencies": {
+ "@phosphor-icons/react": "^2.1.10",
+ "@xyflow/react": "^12.11.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.3.0"
@@ -20,6 +22,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
+ "ajv": "^8.20.0",
"jsdom": "^29.1.1",
"oxfmt": "^0.61.0",
"oxlint": "^1.71.0",
@@ -1144,6 +1147,19 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@phosphor-icons/react": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
+ "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8",
+ "react-dom": ">= 16.8"
+ }
+ },
"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",
@@ -2069,6 +2085,55 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2097,7 +2162,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2107,7 +2172,7 @@
"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,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -2283,6 +2348,65 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.11.2",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
+ "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.79",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.79",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
+ "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -2368,6 +2492,12 @@
"node": ">=18"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2399,9 +2529,114 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -2506,6 +2741,30 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2681,6 +2940,13 @@
}
}
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -3586,6 +3852,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -3835,6 +4110,34 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 4a1c8f03..e500deeb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,6 +15,8 @@
"typecheck": "tsc -b"
},
"dependencies": {
+ "@phosphor-icons/react": "^2.1.10",
+ "@xyflow/react": "^12.11.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.3.0"
@@ -27,6 +29,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.10",
+ "ajv": "^8.20.0",
"jsdom": "^29.1.1",
"oxfmt": "^0.61.0",
"oxlint": "^1.71.0",
diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx
index 7ef95965..c85cfd89 100644
--- a/frontend/src/app/app.test.tsx
+++ b/frontend/src/app/app.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, useLocation } from 'react-router'
import type { AuthTokens, UserApis } from '@/entities'
@@ -23,10 +23,11 @@ function LocationProbe() {
afterEach(() => {
cleanup()
window.localStorage.clear()
+ vi.useRealTimers()
})
describe('AppRoutes authentication boundary', () => {
- it('keeps the home page available to guests', async () => {
+ it('keeps the public landing page available to guests', async () => {
render(
@@ -36,9 +37,59 @@ describe('AppRoutes authentication boundary', () => {
)
expect(await screen.findByRole('heading', { name: /让你的角色/ })).toBeTruthy()
+ expect(screen.getByRole('navigation', { name: '宣传页导航' })).toBeTruthy()
+ expect(screen.queryByRole('navigation', { name: '产品导航' })).toBeNull()
})
- it('redirects a guest before rendering a protected product page and preserves its return path', async () => {
+ it('keeps authenticated users on the public landing page until they enter the workspace', async () => {
+ render(
+
+
+
+
+ ,
+ )
+
+ expect(await screen.findByRole('navigation', { name: '宣传页导航' })).toBeTruthy()
+ expect(screen.getByRole('link', { name: '进入工作台' }).getAttribute('href')).toBe('/workspace')
+ })
+
+ it('protects the workspace home and preserves it as the login return path', async () => {
+ render(
+
+
+
+
+
+ ,
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/?account=login&returnTo=%2Fworkspace',
+ ),
+ )
+ expect(screen.queryByRole('heading', { name: '工作台' })).toBeNull()
+ })
+
+ it('serves the workspace from its dedicated protected route', async () => {
+ render(
+
+
+
+
+ ,
+ )
+
+ expect(await screen.findByRole('heading', { name: '工作台' })).toBeTruthy()
+ expect(screen.queryByRole('heading', { name: /让你的角色/ })).toBeNull()
+ expect(screen.getByRole('navigation', { name: '产品导航' })).toBeTruthy()
+ expect(screen.getByRole('link', { name: '返回 Windup 工作台' }).getAttribute('href')).toBe(
+ '/workspace',
+ )
+ })
+
+ it('redirects a guest before rendering Quick Start and preserves its return path', async () => {
render(
@@ -53,7 +104,25 @@ describe('AppRoutes authentication boundary', () => {
'/?account=login&returnTo=%2Fquick-start%3Fdraft%3D1%23setup',
),
)
- expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull()
+ expect(screen.queryByRole('heading', { name: '想做一个什么角色?' })).toBeNull()
+ })
+
+ it('redirects a guest from the PlayTest entry and preserves that return path', async () => {
+ render(
+
+
+
+
+
+ ,
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/?account=login&returnTo=%2Fplaytest',
+ ),
+ )
+ expect(screen.queryByRole('heading', { name: '选择可预览资产' })).toBeNull()
})
it('protects direct account-center visits and returns there after login', async () => {
@@ -92,13 +161,13 @@ describe('AppRoutes authentication boundary', () => {
,
)
- expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull()
+ expect(screen.queryByRole('heading', { name: '想做一个什么角色?' })).toBeNull()
expect(screen.getByTestId('location').textContent).toBe('/quick-start')
const restoredTokens = await baseApis.refresh('stored-refresh-token')
await act(async () => resolveRefresh(restoredTokens))
- expect(await screen.findByRole('heading', { name: '快速开始' })).toBeTruthy()
+ expect(await screen.findByRole('heading', { name: '想做一个什么角色?' })).toBeTruthy()
})
it('renders protected product pages for an authenticated session', async () => {
@@ -110,7 +179,7 @@ describe('AppRoutes authentication boundary', () => {
,
)
- expect(await screen.findByRole('heading', { name: '快速开始' })).toBeTruthy()
+ expect(await screen.findByRole('heading', { name: '想做一个什么角色?' })).toBeTruthy()
})
it('tells the user when restoring the session fails instead of becoming a silent guest', async () => {
@@ -135,7 +204,7 @@ describe('AppRoutes authentication boundary', () => {
,
)
- expect((await screen.findByRole('alert')).textContent).toContain('登录状态已过期,请重新登录。')
+ expect(await screen.findByText('登录状态已过期,请重新登录。')).toBeTruthy()
expect(screen.getByRole('link', { name: '重新登录' }).getAttribute('href')).toBe(
'/?account=login&returnTo=%2F',
)
@@ -165,19 +234,17 @@ describe('AppRoutes authentication boundary', () => {
)
expect(await screen.findByRole('dialog', { name: '登录 Windup' })).toBeTruthy()
- fireEvent.keyDown(document, { key: 'Escape' })
- await waitFor(() =>
- expect(screen.getByTestId('location').textContent).toBe(
- '/?returnTo=%2Fquick-start%3Fdraft%3D1%23setup',
- ),
+ vi.useFakeTimers()
+ fireEvent.click(screen.getByRole('button', { name: '关闭账号面板' }))
+ await act(async () => vi.runOnlyPendingTimersAsync())
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/?returnTo=%2Fquick-start%3Fdraft%3D1%23setup',
)
+ vi.useRealTimers()
fireEvent.click(screen.getByRole('link', { name: '重新登录' }))
-
- await waitFor(() =>
- expect(screen.getByTestId('location').textContent).toBe(
- '/?account=login&returnTo=%2Fquick-start%3Fdraft%3D1%23setup',
- ),
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/?account=login&returnTo=%2Fquick-start%3Fdraft%3D1%23setup',
)
})
})
diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx
index 156ba45c..50c92009 100644
--- a/frontend/src/app/app.tsx
+++ b/frontend/src/app/app.tsx
@@ -1,24 +1,30 @@
+import { lazy, Suspense } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { AssetLibraryPage } from '@/pages/asset-library'
import { AccountPage } from '@/pages/account'
import { CharacterDetailPage } from '@/pages/character-detail'
-import { HomePage } from '@/pages/home'
+import { LandingPage } from '@/pages/landing'
import { NotFoundPage } from '@/pages/not-found'
-import { PlaytestPage } from '@/pages/playtest'
+import { PlaytestEntryPage } from '@/pages/playtest'
+import { PlaytestExportPage } from './playtest-export-page'
import { ProjectDetailPage } from '@/pages/project-detail'
import { ProjectCreatePage } from '@/pages/project-create'
import { ProjectsPage } from '@/pages/projects'
import { QuickStartPage } from '@/pages/quick-start'
-import { WorkflowEditorPage } from '@/pages/workflow-editor'
+import { WorkspacePage } from '@/pages/workspace'
import { ProtectedRoute } from '@/features/auth-guard'
-import { AppShellRoute } from './layout'
+import { AppShellRoute, MarketingShellRoute } from './layout'
+
+const WorkflowEditorPage = lazy(() =>
+ import('@/pages/workflow-editor').then(({ WorkflowEditorPage: Page }) => ({ default: Page })),
+)
/**
* 路由表与全局外壳。
* 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
- * 外壳的边界画在这张表上:首页与流程页使用全局顶栏;项目工作区使用自己的
- * 项目导航,不重复套全局外壳。
+ * 外壳的边界画在这张表上:公开宣传页与登录产品使用不同外壳;
+ * 项目工作区继续使用自己的项目导航,不重复套产品顶栏。
*/
export function App() {
return (
@@ -28,25 +34,35 @@ export function App() {
)
}
+function LazyWorkflowEditorPage() {
+ return (
+ }>
+
+
+ )
+}
+
/** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */
export function AppRoutes() {
return (
- }>
- } />
- }>
+ }>
+ } />
+ } />
+
+ }>
+ }>
+ } />
} />
} />
} />
} />
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
- } />
-
- }>
}>
} />
} />
diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx
index 3756c100..1fcffa03 100644
--- a/frontend/src/app/layout/app-header.test.tsx
+++ b/frontend/src/app/layout/app-header.test.tsx
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
-import type { AuthTokens, UserApis } from '@/entities'
+import type { AuthTokens, CreditAccount, QuotaApis, UserApis } from '@/entities'
import { AuthSessionProvider } from '@/features/auth-session'
import { AppHeader } from './app-header'
@@ -33,6 +33,27 @@ function createApis(): UserApis & Record
+ listTransactions: ReturnType
+} {
+ return {
+ getBalance: vi.fn(async () => creditAccount),
+ listTransactions: vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 })),
+ }
+}
+
function LocationProbe() {
const location = useLocation()
return (
@@ -40,18 +61,27 @@ function LocationProbe() {
)
}
-function renderHeader(entry = '/', apis = createApis()) {
+function renderHeader(
+ entry = '/',
+ apis = createApis(),
+ previousEntry?: string,
+ quota = createQuotaMock(),
+) {
return {
apis,
+ quota,
...render(
-
+
-
+
>
}
@@ -66,30 +96,115 @@ function renderHeader(entry = '/', apis = createApis()) {
afterEach(() => {
cleanup()
window.localStorage.clear()
+ window.sessionStorage.clear()
+ window.history.replaceState({ idx: 0 }, '')
})
describe('AppHeader', () => {
- it('保留三个产品入口,并将工作流路由归入创作', () => {
+ it.each([
+ ['/quick-start/run-42', '/quick-start'],
+ ['/playtest/7/outfit-8', '/playtest'],
+ ['/projects/new', '/projects'],
+ ['/workflow-editor/run-42', '/workspace'],
+ ['/workspace', '/'],
+ ['/account', '/workspace'],
+ ])('直接打开 %s 时按页面层级返回 %s', (entry, expected) => {
+ window.history.replaceState({ idx: 0 }, '')
+ renderHeader(entry)
+
+ const back = screen.getByRole('button', { name: '返回上一页' })
+ expect(back.getAttribute('title')).toBe('返回上一页')
+ expect(back.className).toContain('h-9')
+ expect(back.className).toContain('w-9')
+
+ fireEvent.click(back)
+ expect(screen.getByTestId('location').textContent).toBe(expected)
+ })
+
+ it('存在站内浏览历史时返回真实上一页', () => {
+ window.history.replaceState({ idx: 1 }, '')
+ renderHeader('/quick-start', createApis(), '/projects')
+
+ fireEvent.click(screen.getByRole('button', { name: '返回上一页' }))
+ expect(screen.getByTestId('location').textContent).toBe('/projects')
+ })
+
+ it('提供预览台入口,并将工作流路由归入创作', () => {
renderHeader('/workflow-editor/run-1')
- expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/')
+ expect(screen.getByRole('banner').getAttribute('data-surface')).toBe('frosted-bar')
+ expect(screen.getByRole('banner').getAttribute('data-motion')).toBeNull()
+ expect(screen.getByRole('link', { name: '返回 Windup 工作台' }).getAttribute('href')).toBe(
+ '/workspace',
+ )
expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects')
expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page')
- expect(screen.queryByRole('link', { name: 'Playtest' })).toBeNull()
+ expect(screen.getByRole('link', { name: '预览台' }).getAttribute('href')).toBe('/playtest')
})
- it('在首页只高亮首页一项', () => {
- renderHeader()
+ it('在工作台首页只高亮首页一项', () => {
+ renderHeader('/workspace')
expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page')
expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull()
expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull()
+ expect(screen.getByRole('link', { name: '预览台' }).getAttribute('aria-current')).toBeNull()
+ })
+
+ it('切换页面后继续播放与品牌一致的文字波浪', () => {
+ renderHeader('/workspace')
+
+ const projects = screen.getByRole('link', { name: '项目资产' })
+ fireEvent.click(projects)
+
+ expect(projects.classList.contains('app-header-text-wave')).toBe(true)
+ expect(
+ screen
+ .getByRole('link', { name: '返回 Windup 工作台' })
+ .classList.contains('app-header-text-wave'),
+ ).toBe(false)
+ expect(screen.getByTestId('location').textContent).toBe('/projects')
+ })
+
+ it('品牌与首页分别播放文字波浪', () => {
+ renderHeader('/projects')
+
+ const brand = screen.getByRole('link', { name: '返回 Windup 工作台' })
+ const home = screen.getByRole('link', { name: '首页' })
+ fireEvent.click(brand)
+
+ expect(brand.classList.contains('app-header-text-wave')).toBe(true)
+ expect(home.classList.contains('app-header-text-wave')).toBe(false)
+ })
+
+ it('连续激活同一入口会重新开始文字波浪', () => {
+ renderHeader('/workspace')
+
+ const projects = screen.getByRole('link', { name: '项目资产' })
+ fireEvent.click(projects)
+ const firstGlyph = projects.querySelector('.app-header-wave-glyph')
+
+ fireEvent.click(projects)
+ const replayedGlyph = projects.querySelector('.app-header-wave-glyph')
+
+ expect(replayedGlyph).not.toBe(firstGlyph)
+ expect(projects.classList.contains('app-header-text-wave')).toBe(true)
+ })
+
+ it('在资产选择页和具体预览台高亮预览台入口', () => {
+ const { unmount } = renderHeader('/playtest')
+
+ expect(screen.getByRole('link', { name: '预览台' }).getAttribute('aria-current')).toBe('page')
+
+ unmount()
+ renderHeader('/playtest/51/outfit-default')
+ expect(screen.getByRole('link', { name: '预览台' }).getAttribute('aria-current')).toBe('page')
})
it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => {
renderHeader('/quick-start?mode=fast#brief')
- const entry = await screen.findByRole('link', { name: '登录 / 注册' })
+ const entry = await screen.findByRole('link', { name: '登录' })
expect(entry.getAttribute('href')).toBe(
'/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief',
)
@@ -99,22 +214,198 @@ describe('AppHeader', () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
const { apis } = renderHeader('/projects')
- expect(await screen.findByText('Reader')).toBeTruthy()
+ const accountMenu = await screen.findByRole('button', { name: '打开账号菜单' })
+ expect(accountMenu.textContent).toContain('Reader')
+ fireEvent.click(accountMenu)
fireEvent.click(screen.getByRole('button', { name: '退出登录' }))
await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/'))
- expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy()
+ expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy()
expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token')
})
+ it('登录工作台后显示一次邀请奖励提示,打开账号菜单时收起', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ renderHeader('/workspace')
+
+ expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy()
+ fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' }))
+
+ expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull()
+ })
+
+ it('邀请提示可以直达邀请奖励,并在关闭或十五秒后收起', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ const timeoutSpy = vi.spyOn(window, 'setTimeout')
+ renderHeader('/workspace')
+
+ const hint = await screen.findByRole('status', { name: '邀请奖励提示' })
+ expect(screen.getByRole('link', { name: '去看看邀请奖励' }).getAttribute('href')).toBe(
+ '/account?section=invite',
+ )
+ const timerCall = timeoutSpy.mock.calls.find(([, delay]) => delay === 15_000)
+ expect(timerCall).toBeTruthy()
+ const timerCallback = timerCall?.[0]
+ expect(typeof timerCallback).toBe('function')
+ act(() => {
+ if (typeof timerCallback === 'function') timerCallback()
+ })
+ expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull()
+
+ window.sessionStorage.clear()
+ cleanup()
+ renderHeader('/workspace')
+ expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: '关闭邀请奖励提示' }))
+ expect(hint.isConnected).toBe(false)
+ })
+
+ it('当前登录会话离开工作台后不重复显示', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ renderHeader('/workspace')
+ expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('link', { name: '项目资产' }))
+ fireEvent.click(screen.getByRole('link', { name: '首页' }))
+ expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull()
+ })
+
+ it('远端退出失败时仍清除本地会话并返回首页', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ const apis = createApis()
+ apis.logout.mockRejectedValue(new Error('退出请求失败'))
+ renderHeader('/projects', apis)
+
+ fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' }))
+ fireEvent.click(screen.getByRole('button', { name: '退出登录' }))
+
+ await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/'))
+ expect(await screen.findByRole('link', { name: '登录' })).toBeTruthy()
+ })
+
+ it('没有昵称时使用邮箱展示账号身份', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ const apis = createApis()
+ apis.refresh.mockResolvedValue({
+ ...tokens(),
+ user: { ...user, nickname: '' },
+ })
+ renderHeader('/workspace', apis)
+
+ const accountMenu = await screen.findByRole('button', { name: '打开账号菜单' })
+ expect(accountMenu.textContent).toContain('reader@example.com')
+ expect(accountMenu.textContent).toContain('r')
+ })
+
it('让登录用户从 Header 的账号信息进入账号中心', async () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
renderHeader('/account')
- const account = await screen.findByRole('link', { name: '打开账号中心' })
+ const accountMenu = await screen.findByRole('button', { name: '打开账号菜单' })
+ const menuSurface = screen.getByTestId('account-menu')
+ expect(menuSurface.getAttribute('data-state')).toBe('closed')
+ expect(menuSurface.getAttribute('aria-hidden')).toBe('true')
+ expect(screen.queryByRole('link', { name: '打开账号中心' })).toBeNull()
+
+ fireEvent.click(accountMenu)
+ expect(menuSurface.getAttribute('data-state')).toBe('open')
+ expect(menuSurface.getAttribute('data-motion')).toBe('scale-fade')
+ expect(menuSurface.getAttribute('aria-hidden')).toBeNull()
+ const account = screen.getByRole('link', { name: '打开账号中心' })
expect(account.getAttribute('href')).toBe('/account')
expect(account.getAttribute('aria-current')).toBe('page')
- expect(account.textContent).toContain('Reader')
- expect(screen.getByText('资料与登录安全')).toBeTruthy()
+ expect(accountMenu.textContent).toContain('Reader')
+ expect(screen.queryByText('资料与登录安全')).toBeNull()
+
+ fireEvent.click(account)
+ expect(menuSurface.getAttribute('data-state')).toBe('closing')
+ expect(screen.getByTestId('location').textContent).toBe('/account')
+ fireEvent.animationEnd(menuSurface)
+ await waitFor(() => expect(menuSurface.getAttribute('data-state')).toBe('closed'))
+
+ fireEvent.click(accountMenu)
+
+ fireEvent.click(accountMenu)
+ expect(menuSurface.getAttribute('data-state')).toBe('closing')
+ expect(menuSurface.getAttribute('aria-hidden')).toBe('true')
+ expect(menuSurface.classList.contains('app-header-account-menu-out')).toBe(true)
+ expect(menuSurface.classList.contains('invisible')).toBe(false)
+ expect(screen.queryByRole('link', { name: '打开账号中心' })).toBeNull()
+
+ await waitFor(() => expect(menuSurface.getAttribute('data-state')).toBe('closed'))
+ expect(menuSurface.classList.contains('invisible')).toBe(true)
+ })
+
+ it('打开账号菜单时查询并展示最新可用积分', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ let resolveBalance: (account: CreditAccount) => void = () => undefined
+ const quota = createQuotaMock()
+ quota.getBalance.mockReturnValue(
+ new Promise((resolve) => {
+ resolveBalance = resolve
+ }),
+ )
+ renderHeader('/workspace', createApis(), undefined, quota)
+
+ fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' }))
+
+ await waitFor(() => expect(quota.getBalance).toHaveBeenCalledTimes(1))
+ expect(screen.getByText('可用积分')).toBeTruthy()
+ expect(screen.getByText('查询中…')).toBeTruthy()
+
+ resolveBalance(creditAccount)
+ expect(await screen.findByText('90')).toBeTruthy()
+ expect(screen.getByText('积分')).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('button', { name: '打开账号菜单' }))
+ expect(screen.getByText('90')).toBeTruthy()
+ })
+
+ it('积分查询失败时保留账号菜单的其他操作', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ const quota = createQuotaMock()
+ quota.getBalance.mockRejectedValue(new Error('积分接口不可用'))
+ renderHeader('/workspace', createApis(), undefined, quota)
+
+ fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' }))
+
+ expect(await screen.findByText('积分暂不可用')).toBeTruthy()
+ expect(screen.getByRole('link', { name: '打开账号中心' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: '退出登录' })).toBeTruthy()
+ })
+
+ it('使用贴顶毛玻璃栏承载品牌、产品导航与账号入口', async () => {
+ window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
+ renderHeader('/workspace')
+
+ const header = screen.getByRole('banner')
+ const navigation = screen.getByRole('navigation', { name: '产品导航' })
+ expect(header.getAttribute('data-layout')).toBe('unified')
+ expect(header.getAttribute('data-surface')).toBe('frosted-bar')
+ expect(header.className).toContain('inset-x-0')
+ expect(header.className).toContain('top-0')
+ expect(header.className).toContain('bg-transparent')
+ expect(header.className).toContain('backdrop-blur-xl')
+ expect(header.className).not.toContain('bg-[#f3f2ec]')
+ expect(header.className).not.toContain('rounded-[10px]')
+ expect(header.className).not.toContain('-translate-x-1/2')
+ expect(navigation.className).not.toContain('hidden')
+ expect(await screen.findByRole('button', { name: '打开账号菜单' })).toBeTruthy()
+ expect(screen.queryByText('角色资产工作台')).toBeNull()
+
+ const animatedEntries = [
+ screen.getByRole('link', { name: '返回 Windup 工作台' }),
+ screen.getByRole('link', { name: '首页' }),
+ screen.getByRole('link', { name: '项目资产' }),
+ screen.getByRole('link', { name: '创作' }),
+ screen.getByRole('link', { name: '预览台' }),
+ ]
+ for (const entry of animatedEntries) {
+ expect(entry.getAttribute('data-motion')).toBe('text-wave')
+ }
+
+ expect(
+ screen.getByRole('link', { name: '项目资产' }).querySelectorAll('.app-header-wave-glyph'),
+ ).toHaveLength('项目资产项目'.length)
})
})
diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx
index 81f11c18..b698d69e 100644
--- a/frontend/src/app/layout/app-header.tsx
+++ b/frontend/src/app/layout/app-header.tsx
@@ -1,98 +1,179 @@
+import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
-import { useAuthSession } from '@/features/auth-session'
+import { quotaApis as defaultQuotaApis } from '@/entities'
+import type { QuotaApis } from '@/entities'
+import { AUTH_SESSION_STORAGE_PREFIX, useAuthSession } from '@/features/auth-session'
+import { useQuotaBalance } from '@/features/quota'
+import { PageBackButton } from './page-back-button'
interface ProductNavigationItem {
+ motionKey: string
to: string
label: string
compactLabel?: string
isActive: (pathname: string) => boolean
}
-/**
- * 三个入口对应三种去处:回首页、看已有资产、做新东西。
- * 07-31 定稿版还有一个 Playtest 项,这里没有搬——本仓库的预览路由是
- * /playtest/:characterId/:outfitId,没有角色和造型就构造不出可用地址,
- * 顶栏给不出一个恒定的链接。等预览有了落地入口再加回来。
- */
+type AccountMenuState = 'closed' | 'open' | 'closing'
+
+export interface AppHeaderProps {
+ quotaApis?: QuotaApis
+}
+
+const accountMenuExitDurationMs = 260
+const inviteHintStorageKey = `${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1`
+
+/** 四个入口对应四种去处:回首页、看资产、做新东西、核验已完成的造型。 */
const productNavigation: ProductNavigationItem[] = [
{
- to: '/',
+ motionKey: 'home',
+ to: '/workspace',
label: '首页',
- isActive: (pathname) => pathname === '/',
+ isActive: (pathname) => pathname === '/workspace',
},
{
+ motionKey: 'projects',
to: '/projects',
label: '项目资产',
compactLabel: '项目',
isActive: (pathname) => pathname.startsWith('/projects'),
},
{
+ motionKey: 'create',
to: '/quick-start',
label: '创作',
isActive: (pathname) =>
pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor'),
},
+ {
+ motionKey: 'playtest',
+ to: '/playtest',
+ label: '预览台',
+ isActive: (pathname) => pathname.startsWith('/playtest'),
+ },
]
-/** 左侧标牌上的第二行,随所在区域变化,让用户知道自己在哪一片。 */
-function getWorkspaceLabel(pathname: string): { title: string; detail: string } {
- if (pathname.startsWith('/account')) {
- return { title: '账号中心', detail: '资料与登录安全' }
- }
-
- if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) {
- return { title: '项目资产', detail: '角色、造型与动作' }
- }
-
- if (pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor')) {
- return { title: '创作工作流', detail: '设定、生成与审核' }
- }
-
- return { title: '角色资产工作台', detail: 'Windup' }
+function WaveText({ playId, text }: { playId: number; text: string }) {
+ return (
+
+ {[...text].map((character, index, characters) => (
+
+ {character}
+
+ ))}
+
+ )
}
/**
- * 跨页面悬浮 Bar 知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。
- * 它读 pathname 只用于高亮当前项与切换标牌文案,不据此决定自己出不出现——
- * 谁带外壳是路由表的事,见 app.tsx。
- * 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。
+ * 跨页面顶栏知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。
+ * 品牌、主导航和账号共用一个平面,避免三个功能层被误读成彼此独立的卡片。
*/
-export function AppHeader() {
+export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) {
const { pathname, search, hash } = useLocation()
const navigate = useNavigate()
const session = useAuthSession()
- const workspace = getWorkspaceLabel(pathname)
+ const [accountMenuState, setAccountMenuState] = useState('closed')
+ const [inviteHintVisible, setInviteHintVisible] = useState(false)
+ const accountMenuOpen = accountMenuState === 'open'
+ const creditBalance = useQuotaBalance(
+ accountMenuState !== 'closed' && session.state.status === 'authenticated',
+ quotaApis,
+ )
+ const [wave, setWave] = useState({ entry: '', playId: 0 })
const accountEntry = `/?${new URLSearchParams({
account: 'login',
returnTo: `${pathname}${search}${hash}`,
})}`
+ useEffect(() => {
+ if (accountMenuState !== 'closing') {
+ return
+ }
+
+ const timer = window.setTimeout(() => setAccountMenuState('closed'), accountMenuExitDurationMs)
+ return () => window.clearTimeout(timer)
+ }, [accountMenuState])
+
+ useEffect(() => {
+ if (pathname !== '/workspace' || session.state.status !== 'authenticated') {
+ setInviteHintVisible(false)
+ return
+ }
+
+ if (window.sessionStorage.getItem(inviteHintStorageKey) === '1') return
+
+ window.sessionStorage.setItem(inviteHintStorageKey, '1')
+ setInviteHintVisible(true)
+
+ const timer = window.setTimeout(() => {
+ setInviteHintVisible(false)
+ }, 15_000)
+
+ return () => window.clearTimeout(timer)
+ }, [pathname, session.state.status])
+
function signOut() {
+ window.sessionStorage.removeItem(inviteHintStorageKey)
const returnHome = () => navigate('/', { replace: true })
void session.logout().then(returnHome, returnHome)
}
- return (
-