From f0828f46f6e17fa4618e84cec00392b999e8b900 Mon Sep 17 00:00:00 2001 From: Zhengyan Liu Date: Tue, 11 Aug 2026 13:43:31 +0800 Subject: [PATCH 01/91] feat(workflow-editor): add real WorkflowRun canvas (#190) * build(frontend): add React Flow dependency The workflow editor needs a maintained graph renderer for its controlled canvas. Add @xyflow/react and record its exact dependency closure in the lockfile. The frontend can build the editor without loading graph code on unrelated routes. * fix(character): align workflow run ownership The backend binds every Character to the WorkflowRun that produced it. Map and serialize workflow_run_id, then update typed fixtures that consume Character. Editors can select the correct character without guessing from project order. * docs(character): document workflow run ownership The existing contract text omitted the backend ownership field and described a missing run property. Document workflow_run_id and the action outfit lookup through the bound Character. Future adapters have an accurate source of truth for character and outfit identity. * feat(workflow-editor): compose real workflow session The editor must recover one persisted WorkflowRun through the main repository interfaces. Compose the controller with project context, the run-bound Character, and an explicit Generation failure boundary. Production routes no longer depend on demo data or a second workflow state machine. * feat(workflow-editor): render controlled workflow canvas The production editor needs to project persisted workflow state without owning business transitions. Render six system node types and parallel action branches through one controlled React Flow canvas. Commands, recovery paths, and transient UI state remain bounded by WorkflowController and the active run. * feat(app): lazy-load workflow editor route React Flow should not increase the initial bundle cost of unrelated application routes. Load the editor behind Suspense while preserving both protected WorkflowRun route forms. Authenticated deep links keep their existing behavior and load the canvas only when requested. * feat(workflow-controller): accept character setup input Manual editor submissions need to persist the configured prompt before generation. Allow configuring nodes to override their input while keeping completed nodes immutable. Generation receives only canonical fields from the persisted setup snapshot. * feat(workflow-editor): add character setup form Manual WorkflowRun entry needs an editable prompt before the setup node is fixed. Render a controlled description field and submit its value through WorkflowController. The form becomes read-only after persistence and keeps the existing visual system. * test(workflow-editor): cover character setup submission The manual setup path must persist edited input without changing the generation payload shape. Exercise the real page and controller from form entry through the fixed setup state. Exact payload assertions prevent UI-only success and accidental nested fields. * docs(workflow-editor): add PR screenshots The UI change needs reviewer-visible desktop and mobile evidence. Add screenshots from the current canvas renderer with blank image placeholders. Reviewers can verify the six-node layout without shipping screenshot mock code. * feat(workflow-editor): publish reviewed character actions Approved animations must become durable Character assets before review state advances. Upsert the reviewed branch into its existing Outfit through the Character API boundary. Retries keep one stable Action ID and reject mismatched Generation results. * test(export): cover reviewed action publishing Character publication needs contract coverage for the nested asset tree. Exercise action mapping, retry replacement, and Generation ownership guards. The tests prevent duplicate or cross-workflow assets from being persisted. * test(workflow-editor): cover asset publication order Review approval must not advance before the Character asset is durable. Exercise the session orchestration and the page command boundary. The tests keep publication failures from producing a passed review node. * fix(workflow-editor): make asset publication state explicit Character publication should not rely on unrelated WorkflowRun or busy-state renders. Return the published Character to page state and limit action updates to the asset tree. The editor preserves character metadata and removes the unused export surface. * test(workflow-editor): cover explicit character refresh Asset publication needs regression coverage independent of review persistence. Exercise preserved character metadata and page state after an approval failure. The tests prevent getter-based refresh coupling from returning. * fix(workflow-editor): scope canvas state to nodes, roles and branches Generation results were keyed by node id alone, so a node holding two tasks silently kept only the last one. Deleted nodes still issued reads, every command reset the viewport, and a single busy slot let a second branch unlock the first mid-flight. Key generation reads by node and role, skip deleted nodes, refit the view only when the node set changes, and hold busy branches in a set. Four ad-hoc race guards collapse into one AbortController per session and one per read. Regression tests cover each case, including two parallel branches keeping their own locks. Pass-through wrappers and swapped variable names are gone. * refactor(workflow-editor): move page styles to tailwind utilities The editor shipped the only page-level stylesheet in the frontend, while index.css states that everything outside it goes through Tailwind utilities. It also carried its own palette of near-identical greens spread across the sheet and the node data. Move layout, card, menu and boundary styles onto the elements as utility classes. The stylesheet keeps only what utilities cannot express: the shared colour variables, React Flow internal overrides, edge states and keyframes. The sheet drops from 487 to 76 lines with no visual change, verified by comparing rendered screenshots before and after. Nested main landmarks become plain containers. * perf(workflow-editor): reuse settled generation results across run updates The controller emits on every persist, and the page answered each emit by re-reading every generation the run refers to. With four action branches a single click issued a dozen GETs for results that had not changed. Keep settled results in a map keyed by task id and serve them from there. Tasks still pending or running are read again, since their status is what the canvas is waiting on, and the map is dropped when the run changes. A probe over one main line plus three action branches drops from seven reads per command to zero, while the first load still costs seven. --- .../workflow-editor-real-runtime-desktop.jpg | Bin 0 -> 60669 bytes .../workflow-editor-real-runtime-mobile.jpg | Bin 0 -> 20197 bytes frontend/API_CONTRACT.md | 3 +- frontend/package-lock.json | 246 +++- frontend/package.json | 1 + frontend/src/app/app.tsx | 18 +- .../src/app/workflow-editor-route.test.tsx | 35 + frontend/src/entities/character/index.test.ts | 4 + frontend/src/entities/character/index.ts | 6 + frontend/src/features/export/index.test.ts | 254 ++++ frontend/src/features/export/index.ts | 119 +- frontend/src/features/generation/index.ts | 4 +- .../workflow-controller/controller.test.ts | 22 +- .../workflow-controller/controller.ts | 20 +- .../workbench/minimal-workbench.test.tsx | 1 + .../pages/playtest/workbench/model.test.ts | 1 + .../src/pages/workflow-editor/index.test.tsx | 1016 +++++++++++++- frontend/src/pages/workflow-editor/index.tsx | 1186 ++++++++++++++++- .../src/pages/workflow-editor/runtime.test.ts | 383 ++++++ frontend/src/pages/workflow-editor/runtime.ts | 151 +++ .../pages/workflow-editor/workflow-editor.css | 76 ++ frontend/src/test/project-assets-backend.ts | 2 + 22 files changed, 3440 insertions(+), 108 deletions(-) create mode 100644 docs/screenshots/workflow-editor-real-runtime-desktop.jpg create mode 100644 docs/screenshots/workflow-editor-real-runtime-mobile.jpg create mode 100644 frontend/src/app/workflow-editor-route.test.tsx create mode 100644 frontend/src/features/export/index.test.ts create mode 100644 frontend/src/pages/workflow-editor/runtime.test.ts create mode 100644 frontend/src/pages/workflow-editor/runtime.ts create mode 100644 frontend/src/pages/workflow-editor/workflow-editor.css 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 0000000000000000000000000000000000000000..5eb99096036c06d42b278c74e09e95f620a62349 GIT binary patch literal 60669 zcmeFa2|SeR+Xwz2TbqjPrLqf!u|;S>Lt__(D4|i78C!N~aWa<7h#ExJBvf`nP6tiM zp6s$^36ZSfe?J&<-c#rIzVG?}Kkxhge{)Ws8PD9$b6@v$U-$Fe*Y*8g*DQTl>VkHt zsUTG#3JM6K0RKTtt# zJ-l4(9Ne7D%=?7*ar5yD3JJ1ti5wOYILs>`C;(rCf|izcGu>v!&6^no*qPY{{?o6e zSI|y6s+-g?R1^mw%AFKcJ1Lg_fH;Au6c8m95S9F*q@V)w($Ru%b$39NloS-yn>NvI zqot&#qN0Wu zhgdqE;};#z#D9^JK5_EW>-uqUQC83+-02T53QDR?;F|+GDIhA)BM^uDHO0
5cB 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 T{0&B4T0ss~WRD`*w~qeCt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f505d5fda36c143f80d550d456f669224879a58b GIT binary patch literal 20197 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 D`%aMC literal 0 HcmV?d00001 diff --git a/frontend/API_CONTRACT.md b/frontend/API_CONTRACT.md index 9aa32e8a..65774e82 100644 --- a/frontend/API_CONTRACT.md +++ b/frontend/API_CONTRACT.md @@ -47,12 +47,13 @@ Character 前端只映射后端真实字段: -- Character:`id`、`project_id`、`name`、`description`、`reference_image_url`、`character_data.version`、`status` +- Character:`id`、`project_id`、`workflow_run_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`。 +创建 Character 时必须传入 `workflow_run_id`;编辑器也只读取与当前 WorkflowRun 绑定的角色,不能从同项目角色中按顺序猜测。 ## 二、本轮明确不实现 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 728a09cd..7d34eaf8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "windup-frontend", "version": "0.0.0", "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" @@ -2069,6 +2070,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 +2147,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 +2157,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 +2333,48 @@ "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/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2368,6 +2460,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 +2497,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", @@ -3586,6 +3789,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 +4047,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..1a7e5301 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc -b" }, "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.3.0" diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 156ba45c..19513838 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { AssetLibraryPage } from '@/pages/asset-library' @@ -10,10 +11,13 @@ 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 { ProtectedRoute } from '@/features/auth-guard' import { AppShellRoute } from './layout' +const WorkflowEditorPage = lazy(() => + import('@/pages/workflow-editor').then(({ WorkflowEditorPage: Page }) => ({ default: Page })), +) + /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 @@ -28,6 +32,14 @@ export function App() { ) } +function LazyWorkflowEditorPage() { + return ( + }> + + + ) +} + /** 路由声明独立导出,测试用 MemoryRouter 验证直达地址。 */ export function AppRoutes() { return ( @@ -40,8 +52,8 @@ export function AppRoutes() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/app/workflow-editor-route.test.tsx b/frontend/src/app/workflow-editor-route.test.tsx new file mode 100644 index 00000000..11e799c4 --- /dev/null +++ b/frontend/src/app/workflow-editor-route.test.tsx @@ -0,0 +1,35 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { MemoryRouter, Outlet } from 'react-router' + +import { AuthenticatedAuthSession } from '@/test/auth-session' + +afterEach(() => { + cleanup() + vi.doUnmock('@/pages/workflow-editor') + vi.doUnmock('./layout') + vi.resetModules() +}) + +it('只有进入 Workflow Editor 路由时才加载 React Flow 页面', async () => { + const pageModuleFactory = vi.fn(() => ({ + WorkflowEditorPage: () =>
懒加载 Workflow Editor
, + })) + vi.doMock('@/pages/workflow-editor', pageModuleFactory) + vi.doMock('./layout', () => ({ AppShellRoute: () => })) + + const { AppRoutes } = await import('./app') + expect(pageModuleFactory).not.toHaveBeenCalled() + + render( + + + + + , + ) + + expect(await screen.findByText('懒加载 Workflow Editor')).toBeTruthy() + expect(pageModuleFactory).toHaveBeenCalledTimes(1) +}) diff --git a/frontend/src/entities/character/index.test.ts b/frontend/src/entities/character/index.test.ts index af0a21de..1b731b11 100644 --- a/frontend/src/entities/character/index.test.ts +++ b/frontend/src/entities/character/index.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const characterDto = { id: 51, project_id: 42, + workflow_run_id: 77, name: '轻装信使', description: null, reference_image_url: 'https://cdn.windup.test/reference.png', @@ -86,6 +87,7 @@ describe('characterApis', () => { { id: '51', projectId: '42', + workflowRunId: '77', name: '轻装信使', description: null, referenceImageUrl: 'https://cdn.windup.test/reference.png', @@ -140,6 +142,7 @@ describe('characterApis', () => { await characterApis.create({ projectId: '42', + workflowRunId: '77', name: '轻装信使', description: '项目角色', referenceImageUrl: null, @@ -148,6 +151,7 @@ describe('characterApis', () => { expect(request?.method).toBe('POST') await expect(request?.json()).resolves.toEqual({ project_id: 42, + workflow_run_id: 77, name: '轻装信使', description: '项目角色', reference_image_url: null, diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 43c2cf6c..6d101e71 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -38,6 +38,8 @@ export interface Outfit { export interface Character { id: string projectId: string + /** 角色由哪一条制作流程产出;Workflow Editor 以它落实单画布单角色。 */ + workflowRunId: string name: string | null description: string | null referenceImageUrl: string | null @@ -50,6 +52,7 @@ export interface Character { /** 创建 Character 记录的字段;生成流程由 Workflow Editor 负责。 */ export interface CreateCharacterInput { projectId: string + workflowRunId: string name?: string | null description?: string | null referenceImageUrl?: string | null @@ -99,6 +102,7 @@ interface CharacterDataDto { interface CharacterDto { id: number project_id: number + workflow_run_id: number name: string | null description: string | null reference_image_url: string | null @@ -149,6 +153,7 @@ function mapCharacter(dto: CharacterDto): Character { return { id: characterId, projectId: String(dto.project_id), + workflowRunId: String(dto.workflow_run_id), name: dto.name, description: dto.description, referenceImageUrl: dto.reference_image_url, @@ -215,6 +220,7 @@ export const characterApis: CharacterApis = { method: 'POST', json: { project_id: toBackendId(input.projectId, 'projectId'), + workflow_run_id: toBackendId(input.workflowRunId, 'workflowRunId'), name: input.name, description: input.description, reference_image_url: input.referenceImageUrl, diff --git a/frontend/src/features/export/index.test.ts b/frontend/src/features/export/index.test.ts new file mode 100644 index 00000000..c07157e1 --- /dev/null +++ b/frontend/src/features/export/index.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest' + +import type { Character, CharacterApis, Generation, WorkflowRun } from '@/entities' + +import * as exportFeature from './index' + +interface ExportFeatureModule { + createCharacterAssetPublisher(apis: Pick): { + publishReviewedAction(input: { + character: Character + workflow: WorkflowRun + reviewNodeId: string + generation: Generation + }): Promise + } +} + +describe('Character asset publisher', () => { + it('publishes the reviewed action into the existing outfit', async () => { + const updates: Character[] = [] + const publisher = ( + exportFeature as unknown as ExportFeatureModule + ).createCharacterAssetPublisher({ + async update(character) { + updates.push(structuredClone(character)) + return structuredClone(character) + }, + }) + + const published = await publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: completeAnimationFixture(), + }) + + expect(updates).toHaveLength(1) + expect(published).toMatchObject({ + description: '人工维护的角色描述', + referenceImageUrl: 'https://assets.windup.test/current-master.png', + }) + expect(published.outfits[0]).toMatchObject({ + id: 'outfit-default', + previewUrl: 'https://assets.windup.test/outfit.png', + }) + expect(published.outfits[0]?.actions).toEqual([ + expect.objectContaining({ id: 'idle', type: 'idle' }), + { + id: 'action-walk', + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + loop: true, + fps: 12, + frameCount: 2, + frames: [ + { index: 0, imageUrl: 'https://assets.windup.test/walk-01.png', durationMs: null }, + { index: 1, imageUrl: 'https://assets.windup.test/walk-02.png', durationMs: null }, + ], + }, + ]) + }) + + it('replaces the same stable action instead of duplicating it on retry', async () => { + const publisher = ( + exportFeature as unknown as ExportFeatureModule + ).createCharacterAssetPublisher({ + async update(character) { + return structuredClone(character) + }, + }) + const input = { + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: completeAnimationFixture(), + } + + const first = await publisher.publishReviewedAction({ character: characterFixture(), ...input }) + const retried = await publisher.publishReviewedAction({ character: first, ...input }) + + expect(retried.outfits[0]?.actions.map((action) => action.id)).toEqual(['idle', 'action-walk']) + }) + + it('rejects a completed task from another project', async () => { + const publisher = createRejectingPublisher() + + await expect( + publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: { ...completeAnimationFixture(), projectId: 'another-project' }, + }), + ).rejects.toThrow('Generation 与当前 WorkflowRun 不匹配') + }) + + it('rejects a task whose type is not complete_animation', async () => { + const publisher = createRejectingPublisher() + const wrongType = { + ...completeAnimationFixture(), + type: 'character_template', + } as unknown as Generation + + await expect( + publisher.publishReviewedAction({ + character: characterFixture(), + workflow: workflowFixture(), + reviewNodeId: 'action-walk:review', + generation: wrongType, + }), + ).rejects.toThrow('完整动画生成结果不可发布') + }) +}) + +function createRejectingPublisher() { + return (exportFeature as unknown as ExportFeatureModule).createCharacterAssetPublisher({ + async update() { + throw new Error('不应写入 Character') + }, + }) +} + +function characterFixture(): Character { + return { + id: '51', + projectId: '42', + workflowRunId: '120', + name: '骑士', + description: '人工维护的角色描述', + referenceImageUrl: 'https://assets.windup.test/current-master.png', + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: null, + previewUrl: 'https://assets.windup.test/outfit.png', + actions: [ + { + id: 'idle', + outfitId: 'outfit-default', + name: '待机', + type: 'idle', + loop: true, + fps: 8, + frameCount: 1, + frames: [ + { + index: 0, + imageUrl: 'https://assets.windup.test/idle-01.png', + durationMs: null, + }, + ], + }, + ], + }, + ], + } +} + +function workflowFixture(): WorkflowRun { + return { + id: '120', + projectId: '42', + version: 7, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '戴红围巾的像素骑士', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: 'https://assets.windup.test/master.png', + }, + { + id: 'action-walk', + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-template'], + generations: [], + error: null, + input: { + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + prompt: '轻快地向前行走', + fps: 12, + }, + selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png', + }, + { + id: 'action-walk:action-generation-method', + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-walk'], + generations: [], + error: null, + method: 'video-cropping', + }, + { + id: 'action-walk:action-full-frame', + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['action-walk:action-generation-method'], + generations: [{ taskId: 'generation-walk', role: 'complete_animation' }], + error: null, + }, + { + id: 'action-walk:review', + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: ['action-walk:action-full-frame'], + generations: [], + error: null, + }, + ], + } +} + +function completeAnimationFixture(): Generation<'complete_animation'> { + return { + id: 'generation-walk', + projectId: '42', + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + frames: [ + { url: 'https://assets.windup.test/walk-01.png' }, + { url: 'https://assets.windup.test/walk-02.png' }, + ], + }, + } +} diff --git a/frontend/src/features/export/index.ts b/frontend/src/features/export/index.ts index adfc5aad..706e80b9 100644 --- a/frontend/src/features/export/index.ts +++ b/frontend/src/features/export/index.ts @@ -1,6 +1,115 @@ -/** 把确认后的造型与动作导出到资产库。 */ -export interface ExportProps { - runId: string - characterId: string - outfitId: string +import type { + Action, + Character, + CharacterApis, + Generation, + WorkflowNode, + WorkflowRun, +} from '@/entities' + +export interface PublishReviewedActionInput { + character: Character + workflow: WorkflowRun + reviewNodeId: string + generation: Generation +} + +export interface CharacterAssetPublisher { + publishReviewedAction(input: PublishReviewedActionInput): Promise +} + +/** + * 审核通过前把完整动画幂等写入 Character 资产树。Action ID 使用首帧节点 ID, + * 因而发布成功但 WorkflowRun 保存失败时可以安全重试,不会追加重复动作。 + */ +export function createCharacterAssetPublisher( + characterApis: Pick, +): CharacterAssetPublisher { + return { + async publishReviewedAction({ character, workflow, reviewNodeId, generation }) { + if (character.workflowRunId !== workflow.id || character.projectId !== workflow.projectId) { + throw new Error('Character 与当前 WorkflowRun 不匹配') + } + + const reviewNode = findNode(workflow, reviewNodeId, 'review') + if (reviewNode.status !== 'active' || reviewNode.phase !== 'reviewing') { + throw new Error('只能发布正在审核的动作') + } + const fullFrameNode = findSingleDependency(workflow, reviewNode, 'action-full-frame') + if (fullFrameNode.status !== 'passed' || fullFrameNode.phase !== 'completed') { + throw new Error('完整动画尚未完成') + } + if (generation.projectId !== workflow.projectId) { + throw new Error('Generation 与当前 WorkflowRun 不匹配') + } + if ( + generation.id !== + fullFrameNode.generations.find((item) => item.role === 'complete_animation')?.taskId || + generation.type !== 'complete_animation' || + generation.status !== 'completed' || + generation.result?.type !== 'complete_animation' || + generation.result.frames.length === 0 + ) { + throw new Error('完整动画生成结果不可发布') + } + + const methodNode = findSingleDependency(workflow, fullFrameNode, 'action-generation-method') + const firstFrameNode = findSingleDependency(workflow, methodNode, 'action-first-frame') + const templateNode = findSingleDependency(workflow, firstFrameNode, 'character-template') + if (!templateNode.selectedImageUrl) throw new Error('角色母版尚未确认') + + const outfitIndex = character.outfits.findIndex( + (outfit) => outfit.id === firstFrameNode.input.outfitId, + ) + if (outfitIndex < 0) throw new Error('动作所属造型不存在') + + const action: Action = { + id: firstFrameNode.id, + outfitId: firstFrameNode.input.outfitId, + name: firstFrameNode.input.name, + type: firstFrameNode.input.type, + loop: firstFrameNode.input.type === 'idle' || firstFrameNode.input.type === 'walk', + fps: firstFrameNode.input.fps, + frameCount: generation.result.frames.length, + frames: generation.result.frames.map((frame, index) => ({ + index, + imageUrl: frame.url, + durationMs: null, + })), + } + const targetOutfit = character.outfits[outfitIndex]! + const actionIndex = targetOutfit.actions.findIndex((item) => item.id === action.id) + const actions = [...targetOutfit.actions] + if (actionIndex >= 0) actions[actionIndex] = action + else actions.push(action) + + const outfits = [...character.outfits] + outfits[outfitIndex] = { ...targetOutfit, actions } + return characterApis.update({ + ...character, + outfits, + }) + }, + } +} + +function findNode( + workflow: WorkflowRun, + nodeId: string, + type: TType, +): Extract { + const node = workflow.nodes.find((candidate) => candidate.id === nodeId) + if (!node || node.type !== type) throw new Error(`目标节点不是 ${type}`) + return node as Extract +} + +function findSingleDependency( + workflow: WorkflowRun, + node: WorkflowNode, + type: TType, +): Extract { + if (node.dependsOnNodeIds.length !== 1) { + throw new Error(`${node.id} 必须且只能依赖一个节点`) + } + return findNode(workflow, node.dependsOnNodeIds[0]!, type) } diff --git a/frontend/src/features/generation/index.ts b/frontend/src/features/generation/index.ts index 20157522..fe628fdd 100644 --- a/frontend/src/features/generation/index.ts +++ b/frontend/src/features/generation/index.ts @@ -3,8 +3,8 @@ export interface GenerationProps { runId: string /** * 目标动作,生成母版等非动作任务可以省略。 - * 动作 ID 只在造型内唯一,所以造型由 runId 对应的 WorkflowRun.outfitId 决定, - * 不能脱离 run 单独使用这个字段。 + * 动作 ID 只在造型内唯一;调用方需用 runId 找到绑定 Character,再从 + * WorkflowRun 动作节点的 input.outfitId 确定造型,不能脱离两者单独使用此字段。 */ actionId?: string } diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 22ce4b2b..54352b97 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -498,16 +498,20 @@ describe('WorkflowController', () => { it('提交角色设定后在母版节点记录任务并进入候选选择', async () => { const { controller, workflow, generation, asyncErrors } = createController() - await controller.generateCharacterTemplate('setup-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.generateCharacterTemplate('setup-1', { + spriteWidth: 64, + spriteHeight: 64, + input: { prompt: '戴红围巾的像素骑士', referenceMedia: [] }, + }) - expect(generation.apis.create).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'character_template', - prompt: '像素骑士', - spriteWidth: 64, - spriteHeight: 64, - }), - ) + expect(generation.apis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: '1', + prompt: '戴红围巾的像素骑士', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }) expect(workflow.getSaved().nodes).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'setup-1', status: 'passed', phase: 'completed' }), diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index a663eb72..df2e4f8f 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -15,6 +15,7 @@ import type { MediaReference, ReviewWorkflowNode, WorkflowActionInput, + WorkflowCharacterInput, WorkflowGenerationRef, WorkflowGenerationRole, WorkflowNode, @@ -35,6 +36,8 @@ export interface AddActionInput { export interface GenerateCharacterTemplateOptions { spriteWidth: number spriteHeight: number + /** 手动编辑器提交时覆盖 configuring 节点的初始输入;节点通过后不再改写。 */ + input?: WorkflowCharacterInput } export interface GenerateActionOptions { @@ -337,8 +340,20 @@ export function createWorkflowController({ if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') { throw new Error('角色设定节点当前不能提交') } + const input = options.input + ? { + prompt: nonEmpty(options.input.prompt, '角色描述'), + referenceMedia: [...options.input.referenceMedia], + } + : setupNode.input return unlockReadyNodes( - replaceNode(run, { ...setupNode, status: 'passed', phase: 'completed', error: null }), + replaceNode(run, { + ...setupNode, + input, + status: 'passed', + phase: 'completed', + error: null, + }), ) }) const templateNode = findSingleDependentNode(advanced, nodeId, 'character-template') @@ -351,7 +366,8 @@ export function createWorkflowController({ projectId: run.projectId, prompt: setupNode.input.prompt, referenceMedia: setupNode.input.referenceMedia, - ...options, + spriteWidth: options.spriteWidth, + spriteHeight: options.spriteHeight, } return input }) diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx index e2b81e3f..0d0806eb 100644 --- a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx +++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx @@ -16,6 +16,7 @@ function frame(index: number, imageUrl: string) { const character: Character = { id: '51', projectId: '42', + workflowRunId: 'workflow-run-51', name: '轻装信使', description: null, referenceImageUrl: null, diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts index 377a7a5c..b762dd33 100644 --- a/frontend/src/pages/playtest/workbench/model.test.ts +++ b/frontend/src/pages/playtest/workbench/model.test.ts @@ -7,6 +7,7 @@ import { createPlaytestModel } from './model' const character: Character = { id: '51', projectId: '42', + workflowRunId: 'workflow-run-51', name: '轻装信使', description: null, referenceImageUrl: null, diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index bb752c0d..40c5f83e 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -1,39 +1,1015 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter, Route, Routes } from 'react-router' +import type { ReactNode } from 'react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Link, MemoryRouter, Route, Routes } from 'react-router' +import type { + Character, + CharacterTemplateWorkflowNode, + Generation, + GenerationApis, + Project, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { createWorkflowController, type WorkflowController } from '@/features/workflow-controller' import { WorkflowEditorPage } from './index' +import type { WorkflowEditorSession } from './runtime' +const { defaultSessionLoader, flowProps, fitView } = vi.hoisted(() => ({ + defaultSessionLoader: vi.fn(), + flowProps: { current: null as Record | null }, + fitView: vi.fn(), +})) + +vi.mock('./runtime', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createDefaultRealWorkflowEditorSession: defaultSessionLoader, + } +}) + +vi.mock('@xyflow/react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Controls: () => null, + Handle: () => null, + ReactFlow: ({ nodes, children, ...props }: TestFlowProps) => { + flowProps.current = { nodes, ...props } + return ( +
+ {nodes.map((node) => ( +
+ {node.data.content} +
+ ))} + {children} +
+ ) + }, + useReactFlow: () => ({ fitView }), + } +}) + +interface TestCanvasNode { + id: string + deletable?: boolean + draggable?: boolean + dragHandle?: string + data: { title: string; content: ReactNode } +} + +interface TestFlowProps extends Record { + nodes: TestCanvasNode[] + children?: ReactNode +} + +beforeEach(() => { + defaultSessionLoader.mockReset() + fitView.mockClear() + flowProps.current = null + window.history.replaceState({}, '', '/') +}) afterEach(cleanup) -describe('WorkflowEditorPage', () => { - it('按六节点顺序展示资产生成方式选择', () => { - const onSelect = vi.fn() +describe('WorkflowEditorPage real runtime boundary', () => { + it('默认路由只恢复真实 WorkflowRun 会话', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + window.history.replaceState({}, '', '/workflow-editor/42') + + renderEditor('/workflow-editor/42') + + expect(await screen.findByText('真实 WorkflowRun 接口')).toBeTruthy() + expect(defaultSessionLoader).toHaveBeenCalledWith('42') + }) + + it('在角色设定卡片提交描述后由 Controller 持久化并固定输入', async () => { + const session = createSession() + defaultSessionLoader.mockResolvedValue(session) + + renderEditor('/workflow-editor/42') + + const promptInput = await screen.findByRole('textbox', { name: '角色描述' }) + expect(screen.queryByRole('textbox', { name: /角色名称/ })).toBeNull() + fireEvent.change(promptInput, { target: { value: '戴红围巾的短发少年冒险家' } }) + fireEvent.click(screen.getByRole('button', { name: '生成角色候选' })) + + await waitFor(() => + expect(session.controller.getWorkflow().nodes[0]).toMatchObject({ + status: 'passed', + phase: 'completed', + input: { prompt: '戴红围巾的短发少年冒险家', referenceMedia: [] }, + }), + ) + await waitFor(() => expect(screen.queryByRole('textbox', { name: '角色描述' })).toBeNull()) + }) + + it('真实 Generation 尚未实现时展示接口错误,不回退到演示候选', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + expect(screen.queryByRole('button', { name: /选择角色候选/ })).toBeNull() + }) + + it('切换 WorkflowRun 时清空上一条任务的临时动作菜单', async () => { + defaultSessionLoader + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('42'))) + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43'))) + render( - + + 下一条 WorkflowRun - } - /> + } /> , ) - expect(screen.getAllByRole('listitem')).toHaveLength(6) - fireEvent.click(screen.getByRole('button', { name: /视频裁剪/ })) - fireEvent.click(screen.getByRole('button', { name: /3D 转 2D/ })) - expect(onSelect.mock.calls).toEqual([['video-cropping'], ['3d-to-2d']]) + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + expect(screen.getByRole('button', { name: '生成动作 ›' })).toBeTruthy() + + fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' })) + await waitFor(() => + expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe( + 'https://assets.windup.test/43.png', + ), + ) + + expect(screen.queryByRole('button', { name: '生成动作 ›' })).toBeNull() }) - it('没有 Controller 装配时不在页面内伪造选择', () => { + it('切换 WorkflowRun 后忽略上一条任务迟到的命令错误', async () => { + const pendingGeneration = deferred() + const createGeneration = vi.fn(() => pendingGeneration.promise) + defaultSessionLoader + .mockResolvedValueOnce( + createSession(workflowFixture(), { + generationApis: generationApisFixture({ create: createGeneration }), + }), + ) + .mockResolvedValueOnce(createSession(completedTemplateWorkflow('43'))) + render( - - + + 下一条 WorkflowRun + + } /> + , ) - expect(screen.getByRole('button', { name: /视频裁剪/ })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: /3D 转 2D/ })).toHaveProperty('disabled', true) + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('link', { name: '下一条 WorkflowRun' })) + await waitFor(() => + expect(screen.getByRole('img', { name: '已确认身份母版' }).getAttribute('src')).toBe( + 'https://assets.windup.test/43.png', + ), + ) + + await act(async () => { + pendingGeneration.reject(new Error('旧 WorkflowRun 的迟到错误')) + await pendingGeneration.promise.catch(() => undefined) + }) + + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('只采用同一 WorkflowRun 内最后一次 Generation 读取结果', async () => { + const oldRead = deferred() + const latestRead = deferred() + defaultSessionLoader.mockResolvedValue(createGenerationRaceSession(oldRead, latestRead)) + + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + await act(async () => { + latestRead.resolve(characterGeneration('new')) + await latestRead.promise + }) + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ) + + await act(async () => { + oldRead.resolve(characterGeneration('old')) + await oldRead.promise + }) + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ) + }) + + it('从 WorkflowRun 绑定角色中明确选择造型,并只提交后端支持的动作类型', async () => { + const session = createSession(completedTemplateWorkflow('42'), { + character: characterFixture(), + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + fireEvent.click(screen.getByRole('button', { name: '生成动作 ›' })) + fireEvent.click(screen.getByRole('button', { name: '选择造型 夜行装' })) + + expect(screen.queryByRole('button', { name: /Jump/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Attack 攻击/ })) + + await waitFor(() => + expect(session.controller.getWorkflow().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'action-first-frame', + // 落库的动作名是中文短名,不含菜单里的英文前缀。 + input: expect.objectContaining({ outfitId: 'night', type: 'attack', name: '攻击' }), + }), + ]), + ), + ) + }) + + it('发布成功但审核保存失败时仍显式刷新 Character', async () => { + const publishReviewedAction = vi.fn(async () => characterFixture()) + const session = createSession(reviewingActionWorkflow(), { + character: { ...characterFixture(), outfits: [] }, + generationApis: generationApisFixture({ + get: vi.fn().mockResolvedValue(completeAnimationGeneration()), + }), + publishReviewedAction, + }) + vi.spyOn(session.controller, 'approveReview').mockRejectedValue(new Error('审核保存失败')) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '审核通过' })) + + expect((await screen.findByRole('alert')).textContent).toContain('审核保存失败') + expect(publishReviewedAction).toHaveBeenCalledWith('action-walk:review') + fireEvent.click(screen.getByRole('button', { name: '添加动作分支' })) + expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe( + false, + ) + }) + + it('同一节点挂多个生成任务时按角色分别读取,后写入的不覆盖前一个', async () => { + const workflow = selectingTemplateWorkflow(3, 'template-task') + workflow.nodes[1] = { + ...(workflow.nodes[1] as CharacterTemplateWorkflowNode), + generations: [ + { taskId: 'template-task', role: 'character_template' }, + { taskId: 'animation-task', role: 'complete_animation' }, + ], + } + defaultSessionLoader.mockResolvedValue( + createSession(workflow, { + generationApis: generationApisFixture({ + get: vi.fn(async (_projectId: string, taskId: string) => + taskId === 'template-task' + ? characterGeneration('template') + : completeAnimationGeneration(), + ) as GenerationApis['get'], + }), + }), + ) + + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('img', { name: '角色候选 1' })).getAttribute('src')).toContain( + '/template.png', + ) + }) + + it('已删除的节点不再读取生成结果', async () => { + const workflow = selectingTemplateWorkflow(3, 'template-task') + workflow.nodes[1] = { + ...(workflow.nodes[1] as CharacterTemplateWorkflowNode), + deletedAt: '2026-08-11T00:00:00.000Z', + } + const get = vi.fn(async () => characterGeneration('template')) + defaultSessionLoader.mockResolvedValue( + createSession(workflow, { + generationApis: generationApisFixture({ get: get as GenerationApis['get'] }), + }), + ) + + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(1)) + + expect(get).not.toHaveBeenCalled() + }) + + it('WorkflowRun 推进但节点集合不变时不重置画布视角', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + await waitFor(() => expect(fitView).toHaveBeenCalled()) + + const afterFirstFit = fitView.mock.calls.length + act(() => controlled.emit(selectingTemplateWorkflow(9, 'another-task'))) + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(fitView.mock.calls.length).toBe(afterFirstFit) + }) + + it('新增动作分支后重新取景,让新节点进入视野', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + await waitFor(() => expect(fitView).toHaveBeenCalled()) + + const afterFirstFit = fitView.mock.calls.length + act(() => controlled.emit(reviewingActionWorkflow())) + await waitFor(() => expect(fitView.mock.calls.length).toBeGreaterThan(afterFirstFit)) + }) + + it('一条动作分支执行命令时不阻塞其他分支的操作', async () => { + const pendingPublish = deferred() + defaultSessionLoader.mockResolvedValue( + createSession(reviewingActionWorkflow(), { + character: characterFixture(), + publishReviewedAction: () => pendingPublish.promise, + }), + ) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '审核通过' })) + expect((screen.getByRole('button', { name: '审核通过' }) as HTMLButtonElement).disabled).toBe( + true, + ) + + fireEvent.click(screen.getByRole('button', { name: '添加动作分支' })) + expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe( + false, + ) + + await act(async () => { + pendingPublish.resolve(characterFixture()) + await pendingPublish.promise + }) + }) + + it('两条分支同时执行命令时,先起的那条不会因为后起的而解锁', async () => { + // 并行分支各自持锁:B 开始执行不能把 A 的锁顶掉,否则 A 的按钮会重新可点, + // 用户再点一次就是对同一个动作重复发布 + 重复审核。 + const pendingA = deferred() + const pendingB = deferred() + const publishReviewedAction = vi.fn((reviewNodeId: string) => + reviewNodeId === 'action-a:review' ? pendingA.promise : pendingB.promise, + ) + defaultSessionLoader.mockResolvedValue( + createSession(reviewingActionWorkflow('action-a', 'action-b'), { + character: characterFixture(), + publishReviewedAction, + }), + ) + renderEditor('/workflow-editor/42') + + const approveButtons = await screen.findAllByRole('button', { name: '审核通过' }) + expect(approveButtons).toHaveLength(2) + const [approveA, approveB] = approveButtons as [HTMLButtonElement, HTMLButtonElement] + + fireEvent.click(approveA) + expect(approveA.disabled).toBe(true) + + fireEvent.click(approveB) + await waitFor(() => expect(publishReviewedAction).toHaveBeenCalledTimes(2)) + + // A 的请求仍未返回,它必须还锁着。 + expect(approveA.disabled).toBe(true) + expect(approveB.disabled).toBe(true) + + fireEvent.click(approveA) + expect(publishReviewedAction).toHaveBeenCalledTimes(2) + + await act(async () => { + pendingA.resolve(characterFixture()) + pendingB.resolve(characterFixture()) + await Promise.all([pendingA.promise, pendingB.promise]) + }) + }) + + it('已经到终态的生成结果只读取一次,后续推进复用缓存', async () => { + // WorkflowRun 每推进一步都会 emit,若每次都重拉全部结果,一条多分支的流程 + // 点一次按钮就是十几个 GET。终态结果不会再变,没有重读的理由。 + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + await waitFor(() => expect(screen.getByRole('img', { name: '角色候选 1' })).toBeTruthy()) + const reads = controlled.session.controller.getGeneration as ReturnType + const afterFirstRead = reads.mock.calls.length + expect(afterFirstRead).toBeGreaterThan(0) + + act(() => controlled.emit(selectingTemplateWorkflow(7, 'old-task'))) + act(() => controlled.emit(selectingTemplateWorkflow(8, 'old-task'))) + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + expect(reads.mock.calls.length).toBe(afterFirstRead) + }) + + it('未到终态的生成任务每次推进都重新读取', async () => { + const controlled = createRestartSelectionSession({ status: 'running' }) + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + const reads = controlled.session.controller.getGeneration as ReturnType + await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(0)) + const afterFirstRead = reads.mock.calls.length + + act(() => controlled.emit(selectingTemplateWorkflow(7, 'old-task'))) + await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(afterFirstRead)) + }) + + it('失败节点可以从当前节点重做', async () => { + const session = createSession(failedTemplateWorkflow()) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + + expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy() + expect(session.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'ready', + error: null, + }) + }) + + it('节点重做后不会沿用上一轮 Generation 的候选选择', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(false) + + act(() => controlled.emit(failedTemplateWorkflow())) + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + act(() => controlled.emit(selectingTemplateWorkflow(6, 'new-task'))) + await waitFor(() => + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ), + ) + + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('Generation 候选集合变化时使旧选择失效', async () => { + const controlled = createRestartSelectionSession() + defaultSessionLoader.mockResolvedValue(controlled.session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + act(() => controlled.emit(selectingTemplateWorkflow(4, 'new-task'))) + await waitFor(() => + expect(screen.getByRole('img', { name: '角色候选 1' }).getAttribute('src')).toContain( + '/new.png', + ), + ) + + expect( + (screen.getByRole('button', { name: '确认身份母版' }) as HTMLButtonElement).disabled, + ).toBe(true) + }) + + it('生成接口提交失败后仍保留角色候选重试入口', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '生成角色候选' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + expect(screen.getByRole('button', { name: '生成角色候选' })).toBeTruthy() + }) + + it('恢复候选读取失败时展示错误并允许重新读取', async () => { + const generationApis = generationApisFixture() + defaultSessionLoader.mockResolvedValue( + createSession(selectingTemplateWorkflow(3, 'existing-task'), { generationApis }), + ) + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + fireEvent.click(screen.getByRole('button', { name: '重试读取生成结果' })) + + await waitFor(() => expect(generationApis.get).toHaveBeenCalledTimes(2)) + }) + + it('恢复生成中节点失败后仍可从该节点重做', async () => { + const session = createSession(generatingTemplateWorkflow()) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + expect((await screen.findByRole('alert')).textContent).toContain('Generation 后端尚未实现') + fireEvent.click(await screen.findByRole('button', { name: '从此节点重做' })) + + expect(await screen.findByRole('button', { name: '生成角色候选' })).toBeTruthy() + expect(session.controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'ready', + error: null, + }) + }) + + it('展示 Controller 的异步错误订阅', async () => { + let reportError: ((error: Error) => void) | null = null + const session = createSession() + session.subscribeErrors = vi.fn((listener) => { + reportError = listener + return () => undefined + }) + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + await screen.findByText('真实 WorkflowRun 接口') + + act(() => reportError?.(new Error('异步保存失败'))) + + expect(screen.getByRole('alert').textContent).toContain('异步保存失败') + }) + + it('把 React Flow 限定为系统节点的拖动画布,不允许自由连线、重连或删除', async () => { + defaultSessionLoader.mockResolvedValue(createSession()) + renderEditor('/workflow-editor/42') + + await waitFor(() => expect(latestFlowProps().nodes).toHaveLength(2)) + + expect(latestFlowProps()).toMatchObject({ + nodesConnectable: false, + edgesReconnectable: false, + deleteKeyCode: null, + }) + expect('onConnect' in latestFlowProps()).toBe(false) + expect(latestFlowProps().nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + deletable: false, + draggable: true, + dragHandle: '.workflow-card__handle', + }), + ]), + ) + expect(latestFlowProps().edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + selectable: false, + deletable: false, + // 未确认的连线画成流动虚线,样式由 workflow-edge--flowing 承担。 + className: 'workflow-edge--flowing', + }), + ]), + ) + }) + + it('没有 runId 时不创建替代 WorkflowRun', () => { + render( + + + } /> + + , + ) + + expect(screen.getByRole('heading', { name: '工作流编辑器' })).toBeTruthy() + expect(screen.getByText('需要从已有 WorkflowRun 进入')).toBeTruthy() + expect(defaultSessionLoader).not.toHaveBeenCalled() }) }) + +function renderEditor(path: string) { + return render( + + + } /> + + , + ) +} + +interface SessionFixtureOptions { + character?: Character | null + generationApis?: GenerationApis + publishReviewedAction?(reviewNodeId: string): Promise +} + +function createSession( + initialWorkflow = workflowFixture(), + options: SessionFixtureOptions = {}, +): WorkflowEditorSession { + let workflow = structuredClone(initialWorkflow) + const workflowRunApis: WorkflowRunApis = { + async create() { + return structuredClone(workflow) + }, + async get() { + return structuredClone(workflow) + }, + async update(next) { + workflow = { ...structuredClone(next), version: workflow.version + 1 } + return structuredClone(workflow) + }, + async remove() {}, + } + const generationApis = options.generationApis ?? generationApisFixture() + const controller = createWorkflowController({ + workflow, + workflowRunApis, + generationApis, + onAsyncError: vi.fn(), + }) + + return { + controller, + project: projectFixture(), + character: options.character ?? null, + publishReviewedAction: + options.publishReviewedAction ?? (() => Promise.reject(new Error('资产发布未装配'))), + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + } +} + +function generationApisFixture(overrides: Partial = {}): GenerationApis { + return { + create: vi.fn(() => + Promise.reject(new Error('Generation 后端尚未实现')), + ) as GenerationApis['create'], + get: vi.fn(() => Promise.reject(new Error('Generation 后端尚未实现'))), + subscribe: vi.fn(() => () => undefined), + ...overrides, + } +} + +function latestFlowProps() { + if (!flowProps.current) throw new Error('React Flow 尚未渲染') + return flowProps.current as { + nodes: TestCanvasNode[] + edges: Array> + nodesConnectable?: boolean + edgesReconnectable?: boolean + deleteKeyCode?: null + } & Record +} + +function workflowFixture(): WorkflowRun { + return { + id: '42', + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'active', + phase: 'configuring', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '短发少年冒险家', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'locked', + phase: 'ready', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: null, + }, + ], + } +} + +function completedTemplateWorkflow(id: string): WorkflowRun { + return { + id, + projectId: '1', + version: 3, + storageStatus: 'active', + nodes: [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '短发少年冒险家', referenceMedia: [] }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: `https://assets.windup.test/${id}.png`, + }, + ], + } +} + +function reviewingActionWorkflow(...actionIds: string[]): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.version = 7 + for (const actionId of actionIds.length > 0 ? actionIds : ['action-walk']) { + workflow.nodes.push( + { + id: actionId, + type: 'action-first-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-template'], + generations: [], + error: null, + input: { + outfitId: 'day', + name: '行走', + type: 'walk', + prompt: null, + fps: 12, + }, + selectedFirstFrameUrl: 'https://assets.windup.test/walk-01.png', + }, + { + id: `${actionId}:method`, + type: 'action-generation-method', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [actionId], + generations: [], + error: null, + method: 'video-cropping', + }, + { + id: `${actionId}:full-frame`, + type: 'action-full-frame', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [`${actionId}:method`], + generations: [{ taskId: `generation-${actionId}`, role: 'complete_animation' }], + error: null, + }, + { + id: `${actionId}:review`, + type: 'review', + status: 'active', + phase: 'reviewing', + dependsOnNodeIds: [`${actionId}:full-frame`], + generations: [], + error: null, + }, + ) + } + return workflow +} + +function failedTemplateWorkflow(): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'failed', + phase: 'generating', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'failed-task', role: 'character_template' }], + error: '候选生成失败', + selectedImageUrl: null, + } + return workflow +} + +function selectingTemplateWorkflow(version: number, taskId: string): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.version = version + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'selecting', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId, role: 'character_template' }], + error: null, + selectedImageUrl: null, + } + return workflow +} + +function generatingTemplateWorkflow(): WorkflowRun { + const workflow = completedTemplateWorkflow('42') + workflow.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'generating', + dependsOnNodeIds: ['character-setup'], + generations: [{ taskId: 'in-flight-task', role: 'character_template' }], + error: null, + selectedImageUrl: null, + } + return workflow +} + +function createGenerationRaceSession( + oldRead: Deferred, + latestRead: Deferred, +): WorkflowEditorSession { + const first = selectingTemplateWorkflow(3, 'old-task') + const latest = selectingTemplateWorkflow(4, 'new-task') + let listener: ((workflow: WorkflowRun) => void) | null = null + let readCount = 0 + const controller = { + getWorkflow: () => structuredClone(latest), + subscribe(nextListener: (workflow: WorkflowRun) => void) { + listener = nextListener + nextListener(structuredClone(first)) + return () => { + listener = null + } + }, + async resume() { + listener?.(structuredClone(latest)) + }, + getGeneration: vi.fn(() => { + readCount += 1 + return readCount === 1 ? oldRead.promise : latestRead.promise + }), + dispose: vi.fn(), + } as unknown as WorkflowController + + return { + controller, + project: projectFixture(), + character: null, + publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + } +} + +function createRestartSelectionSession(options: { status?: Generation['status'] } = {}): { + session: WorkflowEditorSession + emit(workflow: WorkflowRun): void +} { + let current = selectingTemplateWorkflow(3, 'old-task') + let listener: ((workflow: WorkflowRun) => void) | null = null + const emit = (workflow: WorkflowRun) => { + current = structuredClone(workflow) + listener?.(structuredClone(current)) + } + const controller = { + getWorkflow: () => structuredClone(current), + subscribe(nextListener: (workflow: WorkflowRun) => void) { + listener = nextListener + nextListener(structuredClone(current)) + return () => { + listener = null + } + }, + resume: vi.fn(async () => undefined), + getGeneration: vi.fn(async () => { + const taskId = current.nodes[1]?.generations[0]?.taskId + if (!taskId) return null + const generation = characterGeneration(taskId.startsWith('new') ? 'new' : 'old') + return options.status ? { ...generation, status: options.status } : generation + }), + restartFromNode: vi.fn(async () => { + const ready = completedTemplateWorkflow('42') + ready.version = current.version + 1 + ready.nodes[1] = { + id: 'character-template', + type: 'character-template', + status: 'active', + phase: 'ready', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: null, + } + emit(ready) + }), + confirmCharacterTemplate: vi.fn(async () => undefined), + dispose: vi.fn(), + } as unknown as WorkflowController + + return { + session: { + controller, + project: projectFixture(), + character: null, + publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), + subscribeErrors: () => () => undefined, + dispose: () => controller.dispose(), + }, + emit, + } +} + +function characterGeneration(label: string): Generation { + return { + id: `${label}-task`, + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: `https://assets.windup.test/${label}.png` }], + }, + error: null, + } +} + +function completeAnimationGeneration(): Generation<'complete_animation'> { + return { + id: 'generation-walk', + projectId: '1', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: [ + { url: 'https://assets.windup.test/walk-01.png' }, + { url: 'https://assets.windup.test/walk-02.png' }, + ], + }, + error: null, + } +} + +function characterFixture(): Character { + return { + id: '9', + projectId: '1', + workflowRunId: '42', + name: '正式角色', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'day', + characterId: '9', + name: '常态装', + description: null, + previewUrl: null, + actions: [], + }, + { + id: 'night', + characterId: '9', + name: '夜行装', + description: null, + previewUrl: null, + actions: [], + }, + ], + } +} + +interface Deferred { + promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function projectFixture(): Project { + return { + id: '1', + ownerId: '7', + workflowId: null, + name: '正式项目', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 64, height: 64 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-10T00:00:00.000Z', + updatedAt: '2026-08-10T00:00:00.000Z', + } +} diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index ef527e41..cab7bd7a 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -1,90 +1,1150 @@ +import { + Controls, + Handle, + Position, + ReactFlow, + applyNodeChanges, + type Edge, + type Node, + type NodeChange, + type NodeProps, + useReactFlow, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useParams } from 'react-router' -import type { ActionGenerationMethod } from '@/entities' -import { PageContainer } from '@/shared/ui' - -const WORKFLOW_CARDS = [ - { index: '01', title: '角色设定', detail: '输入身份、外观与参考图' }, - { index: '02', title: '角色母版', detail: '生成并确认角色母版' }, - { index: '03', title: '动作首帧', detail: '生成并确认动作起始帧' }, - { index: '04', title: '资产生成方式', detail: '选择视频裁剪或 3D 转 2D' }, - { index: '05', title: '完整动画', detail: '按所选路线生成 32 帧动画' }, - { index: '06', title: '审核', detail: '核验结果并进入 Playtest' }, -] as const +import { + CHARACTER_PERSPECTIVE, + DIRECTIONAL_MOVEMENT, + type ActionFirstFrameWorkflowNode, + type ActionFullFrameWorkflowNode, + type ActionGenerationMethodWorkflowNode, + type Character, + type CharacterSetupWorkflowNode, + type CharacterTemplateWorkflowNode, + type Generation, + type Project, + type ReviewWorkflowNode, + type WorkflowGenerationRole, + type WorkflowNode, + type WorkflowRun, +} from '@/entities' +import type { WorkflowController } from '@/features/workflow-controller' +import { createDefaultRealWorkflowEditorSession, type WorkflowEditorSession } from './runtime' +import './workflow-editor.css' export interface WorkflowEditorPageProps { - selectedGenerationMethod?: ActionGenerationMethod | null - onSelectGenerationMethod?: (method: ActionGenerationMethod) => void + loadSession?: (runId: string) => Promise } -/** 工作流画布骨架;真实状态只来自 WorkflowRun,不在页面内伪造节点推进。 */ -export function WorkflowEditorPage({ - selectedGenerationMethod = null, - onSelectGenerationMethod, -}: WorkflowEditorPageProps = {}) { +type WorkflowCardData = { + title: string + eyebrow: string + status: WorkflowNode['status'] + content: ReactNode +} + +type WorkflowCardNode = Node +type ActionMenuLevel = 'root' | 'outfits' | 'actions' + +/** + * 菜单里的预设动作。label 只用于展示,name 是落进 WorkflowRun 的动作名—— + * 两者分开写,改菜单文案不会连带改掉已经落库的数据。 + */ +const ACTION_PRESETS = [ + { + type: 'idle', + label: 'Idle 待机', + name: '待机', + prompt: '平稳呼吸,重心轻微起伏', + }, + { type: 'walk', label: 'Walk 行走', name: '行走', prompt: '轻快地向前行走' }, + { + type: 'attack', + label: 'Attack 攻击', + name: '攻击', + prompt: '蓄力后向前攻击并回到准备姿态', + }, +] as const + +const ACTION_PRESET_HINT = '预设动作 · 逐帧生成' + +/** 角色设定与身份母版为所有动作分支共用,归在这条虚拟分支下。 */ +const SHARED_BRANCH = 'shared' + +/* + 卡片内部复用三次以上的样式串。原来靠 .workflow-card button 这类后代选择器统一施加, + 搬成工具类后写在这里,好处是能看见哪些元素共用同一套外观,而不是被选择器隐式波及。 + nodrag/nopan/nowheel 是 React Flow 的约定类:让卡片内的交互不被画布手势吞掉。 +*/ +const CARD_STACK = 'grid gap-[17px] nodrag nopan nowheel' + +const CARD_BUTTON = + 'min-h-[42px] rounded-lg border border-[#34533d] bg-[#34533d] px-3 py-[9px] text-[11px] ' + + 'font-[750] text-[#edf4ee] enabled:hover:border-[#263f2e] enabled:hover:bg-[#263f2e] ' + + 'aria-pressed:border-[#263f2e] aria-pressed:bg-[#263f2e] disabled:cursor-not-allowed ' + + 'disabled:border-[#e2e5e1] disabled:bg-[#e2e5e1] disabled:text-[#969d97]' + +/** 缩略图按钮:沿用卡片按钮的尺寸约定,但换成浅底,让图片自己当主角。 */ +const THUMB_BUTTON = + 'min-h-[42px] rounded-lg border border-[var(--editor-line)] bg-[#fbfcfb] p-1 ' + + 'aria-pressed:border-[var(--editor-ink)] aria-pressed:bg-white ' + + 'aria-pressed:shadow-[0_0_0_2px_rgb(31_35_41_/_12%)] disabled:cursor-not-allowed' + +const THUMB_IMAGE = 'block aspect-square w-full rounded-lg object-cover' + +/** 已确认的母版/首帧:像素资产按原样放大,不做平滑。 */ +const MASTER_IMAGE = + 'block aspect-square w-full rounded-xl border border-[var(--editor-line)] bg-[#f6f7f6] ' + + 'object-cover [image-rendering:pixelated]' + +const CARD_SUMMARY = + 'm-0 rounded-[10px] border border-[var(--editor-line)] bg-[#f6f7f6] px-3 py-2.5 ' + + 'text-[11px] leading-[1.6] text-[var(--editor-muted)]' + +const CARD_TEXT = 'm-0 text-[11px] leading-[1.6] text-[var(--editor-muted)]' + +/** 加号菜单里的条目:撑满菜单宽度的两行文字,跟卡片主按钮完全不同。 */ +const MENU_ITEM = + 'flex min-h-0 cursor-pointer flex-col gap-0.5 border-0 px-3 py-[9px] text-left ' + + 'text-[var(--editor-ink)] not-first:border-t not-first:border-t-[rgb(31_35_41_/_6%)] ' + + 'enabled:hover:bg-[#f2f4f2] disabled:cursor-not-allowed disabled:opacity-45' + +/** + * 菜单里的头一条:返回上一级,或 root 层的主入口。比其余条目弱一档, + * 原样式靠 :first-child 选择器实现,这里改成显式挂类,位置换了也不会失灵。 + */ +const MENU_ITEM_LEAD = 'font-medium text-[var(--editor-muted)]' + +const MENU_ITEM_TITLE = 'text-xs font-semibold' +const MENU_ITEM_HINT = 'text-[10px] text-[var(--editor-muted)]' + +const nodeTypes = { 'workflow-card': WorkflowCard } + +/** + * 页面只订阅 Controller 的 WorkflowRun 并把它投影为画布;选择、菜单、位置和 busy + * 都是临时 UI 状态,不会写出第二份流程状态机。 + */ +export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {}) { const { runId } = useParams<{ runId: string }>() + const [session, setSession] = useState(null) + const [character, setCharacter] = useState(null) + const [run, setRun] = useState(null) + const [generations, setGenerations] = useState>({}) + const [selectedImages, setSelectedImages] = useState>({}) + const [actionMenuOpen, setActionMenuOpen] = useState(false) + const [actionMenuLevel, setActionMenuLevel] = useState('root') + const [selectedOutfitId, setSelectedOutfitId] = useState(null) + /** 正在执行命令的分支。必须是集合:并行分支各自持锁,后起的不能顶掉先起的。 */ + const [busyBranches, setBusyBranches] = useState>(() => new Set()) + const [error, setError] = useState(null) + const [resumeError, setResumeError] = useState(null) + const [generationReadError, setGenerationReadError] = useState(null) + const [canvasNodes, setCanvasNodes] = useState([]) + /** 当前会话的有效期。换 WorkflowRun 就 abort,所有异步回调只认这一个信号。 */ + const sessionAbortRef = useRef(null) + /** 最近一次生成结果读取。同一会话内被新的读取顶掉时 abort,避免旧结果盖住新结果。 */ + const latestReadAbortRef = useRef(null) + /** 已到终态的生成结果,按 taskId 记住,避免每次推进都重拉一遍。 */ + const settledGenerationsRef = useRef(new Map()) + + const requestGenerations = useCallback( + (targetSession: WorkflowEditorSession, targetRun: WorkflowRun, sessionSignal: AbortSignal) => { + latestReadAbortRef.current?.abort() + const readAbort = new AbortController() + latestReadAbortRef.current = readAbort + const outdated = () => sessionSignal.aborted || readAbort.signal.aborted + void readGenerations(targetSession.controller, targetRun, settledGenerationsRef.current) + .then((results) => { + if (outdated()) return + setGenerations(results) + setGenerationReadError(null) + }) + .catch((cause: unknown) => { + if (outdated()) return + setGenerationReadError(errorMessage(cause, '读取生成结果失败')) + }) + }, + [], + ) + + const runCommand = useCallback( + (branchKey: string, command: () => Promise) => { + // 同一分支内互斥,防重复提交;别的分支照常能操作,也不会被这条命令解锁。 + if (busyBranches.has(branchKey)) return + const sessionSignal = sessionAbortRef.current?.signal + setBusyBranches((current) => new Set(current).add(branchKey)) + setError(null) + void command() + .catch((cause: unknown) => { + if (sessionSignal?.aborted) return + setError(errorMessage(cause, '工作流命令执行失败')) + }) + .finally(() => { + if (sessionSignal?.aborted) return + setBusyBranches((current) => { + if (!current.has(branchKey)) return current + const next = new Set(current) + next.delete(branchKey) + return next + }) + }) + }, + [busyBranches], + ) + + useEffect(() => { + const sessionAbort = new AbortController() + sessionAbortRef.current = sessionAbort + latestReadAbortRef.current?.abort() + settledGenerationsRef.current = new Map() + setSession(null) + setCharacter(null) + setRun(null) + setGenerations({}) + setSelectedImages({}) + setActionMenuOpen(false) + setActionMenuLevel('root') + setSelectedOutfitId(null) + setBusyBranches(new Set()) + setError(null) + setResumeError(null) + setGenerationReadError(null) + setCanvasNodes([]) + if (!runId) return + + let loaded: WorkflowEditorSession | null = null + let unsubscribe: () => void = () => undefined + let unsubscribeErrors: () => void = () => undefined + const loader = loadSession ?? createDefaultRealWorkflowEditorSession + const signal = sessionAbort.signal + + void loader(runId) + .then(async (nextSession) => { + if (signal.aborted) { + nextSession.dispose() + return + } + loaded = nextSession + setSession(nextSession) + setCharacter(nextSession.character) + unsubscribeErrors = nextSession.subscribeErrors((nextError) => { + if (signal.aborted) return + setError(errorMessage(nextError, '工作流异步处理失败')) + }) + unsubscribe = nextSession.controller.subscribe((nextRun) => { + if (signal.aborted) return + setRun(nextRun) + requestGenerations(nextSession, nextRun, signal) + }) + try { + await nextSession.controller.resume() + } catch (cause: unknown) { + if (signal.aborted) return + setResumeError(errorMessage(cause, '恢复 WorkflowRun 失败')) + } + }) + .catch((cause: unknown) => { + if (signal.aborted) return + setError(errorMessage(cause, '恢复 WorkflowRun 失败')) + }) + + return () => { + sessionAbort.abort() + latestReadAbortRef.current?.abort() + unsubscribe() + unsubscribeErrors() + loaded?.dispose() + } + }, [loadSession, requestGenerations, runId]) + + const projected = useMemo( + () => + run && session + ? projectCanvas({ + run, + controller: session.controller, + publishReviewedAction: session.publishReviewedAction, + project: session.project, + character, + generations, + selectedImages, + actionMenuOpen, + actionMenuLevel, + selectedOutfitId, + busyBranches, + resumeBlocked: Boolean(resumeError), + setSelectedImages, + setActionMenuOpen, + setActionMenuLevel, + setSelectedOutfitId, + setCharacter, + runCommand, + }) + : { nodes: [] as WorkflowCardNode[], edges: [] as Edge[] }, + [ + actionMenuOpen, + actionMenuLevel, + busyBranches, + character, + generations, + run, + runCommand, + resumeError, + selectedImages, + selectedOutfitId, + session, + ], + ) + + useEffect(() => { + setCanvasNodes((previous) => + projected.nodes.map((node) => ({ + ...node, + position: previous.find((candidate) => candidate.id === node.id)?.position ?? node.position, + })), + ) + }, [projected.nodes]) + + useEffect(() => { + if ( + resumeError && + run && + !run.nodes.some( + (node) => !node.deletedAt && node.status === 'active' && node.phase === 'generating', + ) + ) { + setResumeError(null) + } + }, [resumeError, run]) + + function onNodesChange(changes: NodeChange[]) { + const safeChanges = changes.filter((change) => change.type !== 'remove') + setCanvasNodes((nodes) => applyNodeChanges(safeChanges, nodes)) + } + + if (!runId) { + return + } + + if (error && !run) { + return + } + + if (!session || !run) { + return + } + + const constraints = [ + CHARACTER_PERSPECTIVE[session.project.perspective], + DIRECTIONAL_MOVEMENT[session.project.directionalMovement], + `${session.project.spriteSize.width} × ${session.project.spriteSize.height}`, + session.project.gameStyle ?? '未设置画风', + ] + const visibleError = error ?? resumeError ?? generationReadError + return ( - -
-
-

WORKFLOW EDITOR

-

- {runId ? `工作流 ${runId.slice(0, 8)}` : '工作流画布'} -

-

- 首帧确认后先选择资产生产路线,再进入完整动画生成。 +

+
- -
    - {WORKFLOW_CARDS.map((card) => ( -
  1. - {card.index} -

    {card.title}

    -

    {card.detail}

    - {card.index === '04' ? ( - - ) : null} -
  2. - ))} -
+

{session.project.name}

+ +

+ {constraints.join(' · ')} +

+
+ + 真实 WorkflowRun 接口 + + + Run {run.id} · v{run.version} + +
+ + {visibleError ? ( +
+ {visibleError} + {!error && !resumeError && generationReadError ? ( + + ) : null} +
+ ) : null} +
+ + nodes={canvasNodes} + edges={projected.edges} + nodeTypes={nodeTypes} + onNodesChange={onNodesChange} + nodesDraggable + nodesConnectable={false} + edgesReconnectable={false} + elementsSelectable + deleteKeyCode={null} + fitView + fitViewOptions={{ padding: 0.14, maxZoom: 0.82 }} + minZoom={0.3} + maxZoom={1.2} + > + node.id)} /> + +
- + ) } -function GenerationMethodChoice({ - selected, - onSelect, +/** + * 只有画布上的节点增减时才重新取景,让新长出来的分支进入视野。 + * 节点内容变化(生成结果到达、状态推进、版本号增加)一律不动视角—— + * 用户拖过、缩放过的位置是他自己选的,命令执行不该把它拽回全景。 + * + * 延迟一帧再取景:新节点要等 React Flow 量完尺寸,立刻调会按旧尺寸算错取景框。 + */ +function FitViewOnNodeSetChange({ nodeIds }: { nodeIds: string[] }) { + const { fitView } = useReactFlow() + const signature = nodeIds.join(',') + useEffect(() => { + if (!signature) return + const timer = window.setTimeout(() => { + void fitView({ padding: 0.14, maxZoom: 0.82, duration: 180 }) + }, 32) + return () => window.clearTimeout(timer) + }, [fitView, signature]) + return null +} + +interface ProjectionInput { + run: WorkflowRun + controller: WorkflowController + publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise + project: Project + character: Character | null + generations: Record + selectedImages: Record + actionMenuOpen: boolean + actionMenuLevel: ActionMenuLevel + selectedOutfitId: string | null + busyBranches: ReadonlySet + resumeBlocked: boolean + setSelectedImages: React.Dispatch>> + setActionMenuOpen(open: boolean): void + setActionMenuLevel(level: ActionMenuLevel): void + setSelectedOutfitId(outfitId: string | null): void + setCharacter(character: Character): void + runCommand(branchKey: string, command: () => Promise): void +} + +/** 卡片自己所属的分支;命令与禁用判断都以它为准。 */ +function branchKeyOf(node: WorkflowNode, input: ProjectionInput): string { + return branchKeyFor(node, new Map(input.run.nodes.map((candidate) => [candidate.id, candidate]))) +} + +function projectCanvas(input: ProjectionInput): { + nodes: WorkflowCardNode[] + edges: Edge[] +} { + const activeNodes = input.run.nodes.filter((node) => !node.deletedAt) + const actionRootIds = activeNodes + .filter((node) => node.type === 'action-first-frame') + .map((node) => node.id) + const nodesById = new Map(activeNodes.map((node) => [node.id, node])) + const nodes = activeNodes.map((node) => + toCanvasNode(node, branchIndexFor(branchKeyFor(node, nodesById), actionRootIds), input), + ) + const edges: Edge[] = activeNodes.flatMap((node) => { + const confirmed = node.status === 'passed' + return node.dependsOnNodeIds.map((source) => ({ + id: `${source}->${node.id}`, + source, + target: node.id, + selectable: false, + deletable: false, + className: confirmed ? 'workflow-edge--confirmed' : 'workflow-edge--flowing', + })) + }) + + return { nodes, edges } +} + +function toCanvasNode( + node: WorkflowNode, + branchIndex: number, + input: ProjectionInput, +): WorkflowCardNode { + return { + id: node.id, + type: 'workflow-card', + position: positionFor(node.type, branchIndex), + zIndex: node.type === 'character-template' && input.actionMenuOpen ? 1000 : 0, + draggable: true, + dragHandle: '.workflow-card__handle', + deletable: false, + data: { + eyebrow: CARD_LABELS[node.type].eyebrow, + title: CARD_LABELS[node.type].title, + status: node.status, + content: contentFor(node, input), + }, + } +} + +function contentFor(node: WorkflowNode, input: ProjectionInput): ReactNode { + if (node.type === 'character-setup') return + if (node.type === 'character-template') { + return + } + if (node.type === 'action-first-frame') return + if (node.type === 'action-generation-method') return + if (node.type === 'action-full-frame') return + return +} + +function CharacterSetupContent({ + node, + input, }: { - selected: ActionGenerationMethod | null - onSelect?: (method: ActionGenerationMethod) => void + node: CharacterSetupWorkflowNode + input: ProjectionInput }) { + const branchKey = branchKeyOf(node, input) + const branchBusy = input.busyBranches.has(branchKey) + const [prompt, setPrompt] = useState(node.input.prompt) + useEffect(() => setPrompt(node.input.prompt), [node.id, node.input.prompt]) + + if (node.status === 'failed') return + if (node.status === 'passed') return

角色描述已确认

return ( -
+
+