Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/superpowers/plans/2026-08-13-first-frame-image-candidates.md
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 79 additions & 57 deletions frontend/src/entities/generation/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,26 +120,8 @@ describe('createGenerationApis', () => {
})
})

it('通过动作生成接口固定请求并映射一帧动作首帧', async () => {
const request = vi.fn(async (_url: string, _init?: RequestInit) =>
success(
taskData({
task_type: 'character_action',
input_payload: { num_frames: 1, action_type: 'idle' },
result: {
type: 'character_action',
action_type: 'idle',
frames: [
{
index: 0,
image_url: 'https://cdn.test/first-frame.png',
duration_ms: null,
},
],
},
}),
),
)
it('根据角色母版和动作提示词生成三张动作首帧候选', async () => {
const request = vi.fn(async (_url: string, _init?: RequestInit) => success(taskData()))
const apis = createGenerationApis({
baseUrl: '',
transport: { request, stream: vi.fn(() => vi.fn()) },
Expand All @@ -148,27 +130,49 @@ describe('createGenerationApis', () => {
const generation = await apis.create({
type: 'first_frame',
projectId: '42',
characterId: '5',
outfitId: 'default',
actionType: 'idle',
prompt: 'stand naturally',
referenceMedia: [reference('https://cdn.test/template.png')],
spriteWidth: 64,
spriteHeight: 96,
})

expect(request.mock.calls[0]?.[0]).toBe('/generation/action')
expect(request.mock.calls[0]?.[0]).toBe('/generation/image')
expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual({
project_id: 42,
character_id: 5,
action_type: 'idle',
custom_prompt: 'stand naturally',
reference_video_url: null,
reference_image_urls: ['https://cdn.test/template.png'],
num_frames: 1,
reference_image_url: 'https://cdn.test/template.png',
prompt: 'stand naturally',
negative_prompt: '',
width: 64,
height: 96,
num_images: 3,
})
expect(generation.result).toEqual({
type: 'first_frame',
image: { url: 'https://cdn.test/first-frame.png' },
images: [
{ url: 'https://cdn.test/candidate-1.png' },
{ url: 'https://cdn.test/candidate-2.png' },
{ url: 'https://cdn.test/candidate-3.png' },
],
})
})

it('没有角色母版时拒绝创建动作首帧任务', async () => {
const apis = createGenerationApis({
transport: { request: vi.fn(), stream: vi.fn(() => vi.fn()) },
})

await expect(
apis.create({
type: 'first_frame',
projectId: '42',
actionType: 'walk',
prompt: 'walk',
referenceMedia: [],
spriteWidth: 64,
spriteHeight: 96,
}),
).rejects.toThrow('动作首帧生成必须提供已确认的角色母版')
})

it('以首帧请求完整动画并按后端 index 排序,当前合同固定为三十二帧', async () => {
Expand Down Expand Up @@ -616,39 +620,57 @@ describe('createGenerationApis', () => {
)
})

it('推断动作首帧阶段,并拒绝无法推断的动作输入', async () => {
const firstFrame = taskData({
task_type: 'character_action',
input_payload: { num_frames: 1, action_type: 'idle' },
it('按显式阶段恢复图片任务为三张动作首帧候选', async () => {
const request = vi.fn().mockResolvedValueOnce(success(taskData()))
const apis = createGenerationApis({
transport: { request, stream: vi.fn(() => vi.fn()) },
})

await expect(
apis.get('42', '91', { type: 'first_frame', actionType: 'idle' }),
).resolves.toMatchObject({
type: 'first_frame',
result: {
type: 'character_action',
action_type: 'idle',
frames: [{ index: 0, image_url: 'https://cdn.test/first.png', duration_ms: null }],
type: 'first_frame',
images: [
{ url: 'https://cdn.test/candidate-1.png' },
{ url: 'https://cdn.test/candidate-2.png' },
{ url: 'https://cdn.test/candidate-3.png' },
],
},
})
const request = vi
.fn()
.mockResolvedValueOnce(success(firstFrame))
.mockResolvedValueOnce(
success(taskData({ task_type: 'character_action', input_payload: null })),
)
.mockResolvedValueOnce(
success(
taskData({
task_type: 'character_action',
input_payload: { num_frames: 2, action_type: 'walk' },
}),
),
)
})

it('订阅图片任务时按首帧阶段映射三张候选', () => {
let streamOptions: EventStreamOptions | undefined
const onEvent = vi.fn()
const apis = createGenerationApis({
transport: { request, stream: vi.fn(() => vi.fn()) },
transport: {
request: vi.fn(),
stream: vi.fn((_url, options) => {
streamOptions = options
return vi.fn()
}),
},
})

await expect(apis.get('42', '91')).resolves.toMatchObject({ type: 'first_frame' })
await expect(apis.get('42', '91')).rejects.toThrow('动作任务缺少 input_payload')
await expect(apis.get('42', '91')).rejects.toThrow(
'input_payload.num_frames 无法映射到前端阶段',
)
apis.subscribe('42', '91', { type: 'first_frame', actionType: 'walk' }, onEvent, vi.fn())
streamOptions?.onEvent(JSON.stringify(taskData()), 'completed')

expect(onEvent).toHaveBeenCalledWith({
taskId: '91',
type: 'first_frame',
status: 'completed',
result: {
type: 'first_frame',
images: [
{ url: 'https://cdn.test/candidate-1.png' },
{ url: 'https://cdn.test/candidate-2.png' },
{ url: 'https://cdn.test/candidate-3.png' },
],
},
error: null,
})
})

it.each([
Expand Down
Loading
Loading