diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py index e7bebbf6b..81267ff1d 100644 --- a/backend/packages/app/src/windup_app/web/api/project.py +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -97,11 +97,28 @@ class ProjectOut(BaseModel): directional_movement: int sprite_width: int sprite_height: int - game_style: str | None + game_style: ArtStyle | str sprite_sample_url: str | None create_at: datetime update_at: datetime + @field_validator("game_style", mode="before") + @classmethod + def _normalize_style(cls, value: object) -> ArtStyle | str: + """库里既有枚举码也有存量自由文本。 + + 认得出的归一成枚举码,认不出的**原样交出去** —— 压成 ``UNSPECIFIED`` 会让接口 + 与生成时实际用的画风对不上:库里存着「中世纪厚涂」、提示词里也用着它,而接口说 + 这个项目没设画风。 + """ + if not isinstance(value, str) or not value.strip(): + return ArtStyle.UNSPECIFIED + text = value.strip() + try: + return ArtStyle(text) + except ValueError: + return ArtStyle.PIXEL if ArtStyle.from_stored(text) is ArtStyle.PIXEL else text + class ProjectListOut(ProjectOut): """项目列表项;预览是读取投影,不写回 Project。""" diff --git a/backend/tests/test_art_style.py b/backend/tests/test_art_style.py index 17c69df9d..29d4291ef 100644 --- a/backend/tests/test_art_style.py +++ b/backend/tests/test_art_style.py @@ -80,10 +80,27 @@ def test_created_project_reports_the_chosen_style(auth_client): assert created["game_style"] == "pixel" -def test_unspecified_is_stored_as_null(auth_client): - """现有前端把这一列原样显示,写字面量会让「不指定」四个字变成 unspecified。""" +def test_unspecified_is_stored_as_null(auth_client, db_session): + """库里存 NULL 而不是字面量;响应侧再归一成枚举交给前端。""" + from windup_app.server.project.model import Project + created = auth_client.post("/projects", json=_payload()).json()["data"] - assert created["game_style"] is None + assert created["game_style"] == "unspecified" + assert db_session.get(Project, created["id"]).game_style is None + + +def test_reading_a_legacy_row_reports_the_branch_it_takes(auth_client, db_session): + """存量存的是自由文本,不归一的话前端会把「像素风格」显示成不指定。""" + from windup_app.server.project.model import Project + + created = auth_client.post("/projects", json=_payload()).json()["data"] + project = db_session.get(Project, created["id"]) + project.game_style = "像素风格" + db_session.commit() + + body = auth_client.get(f"/projects/{created['id']}").json() + assert body["code"] == 200 + assert body["data"]["game_style"] == "pixel" # -- 改画风 ------------------------------------------------------------------ @@ -218,3 +235,15 @@ def test_no_phrase_uses_a_negation(style): errors = [i for i in lint(style.prompt_phrase, kind="still") if i.level == "error"] assert errors == [], f"{style.value}: {[i.message for i in errors]}" + + +def test_reading_a_non_pixel_legacy_row_tells_the_truth(auth_client, db_session): + """接口不能说「没设画风」而生成时又在用那段文字 —— 认不出的原样交出去。""" + from windup_app.server.project.model import Project + + created = auth_client.post("/projects", json=_payload()).json()["data"] + db_session.get(Project, created["id"]).game_style = "中世纪厚涂" + db_session.commit() + + body = auth_client.get(f"/projects/{created['id']}").json() + assert body["data"]["game_style"] == "中世纪厚涂" diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index beb339bf4..29a5347d0 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -23,13 +23,18 @@ export type { /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { + ART_STYLE, + ART_STYLE_HINT, + ART_STYLE_OPTIONS, CHARACTER_PERSPECTIVE, + isArtStyle, DIRECTIONAL_MOVEMENT, ProjectHasCharactersError, ProjectNameConflictError, } from './project' export { projectApis } from './project' export type { + ArtStyle, CharacterPerspective, CreateProjectInput, DirectionalMovement, diff --git a/frontend/src/entities/project/index.test.ts b/frontend/src/entities/project/index.test.ts index 322ef23d8..38a227ab0 100644 --- a/frontend/src/entities/project/index.test.ts +++ b/frontend/src/entities/project/index.test.ts @@ -60,7 +60,7 @@ describe('projectApis', () => { perspective: 'isometric', directionalMovement: 'four-way', spriteSize: { width: 64, height: 96 }, - gameStyle: null, + gameStyle: 'unspecified', sampleImageUrl: 'https://cdn.windup.test/style.png', previewUrl: 'https://cdn.windup.test/project-preview.png', createdAt: '2026-08-01T08:00:00Z', @@ -87,7 +87,7 @@ describe('projectApis', () => { perspective: 'isometric', directionalMovement: 'four-way', spriteSize: { width: 64, height: 96 }, - gameStyle: null, + gameStyle: 'unspecified', sampleImageUrl: 'https://cdn.windup.test/style.png', }) @@ -100,7 +100,7 @@ describe('projectApis', () => { directional_movement: 2, sprite_width: 64, sprite_height: 96, - game_style: null, + game_style: 'unspecified', sprite_sample_url: 'https://cdn.windup.test/style.png', }) }) diff --git a/frontend/src/entities/project/index.ts b/frontend/src/entities/project/index.ts index 0d3c06983..1e668a39b 100644 --- a/frontend/src/entities/project/index.ts +++ b/frontend/src/entities/project/index.ts @@ -9,7 +9,7 @@ export interface Project { perspective: CharacterPerspective directionalMovement: DirectionalMovement spriteSize: { width: number; height: number } - gameStyle: string | null + gameStyle: ArtStyle sampleImageUrl: string | null /** 项目列表已经解析好的卡片预览;详情响应不承诺提供。 */ previewUrl?: string | null @@ -27,7 +27,7 @@ export interface CreateProjectInput { perspective: CharacterPerspective directionalMovement: DirectionalMovement spriteSize: { width: number; height: number } - gameStyle?: string | null + gameStyle?: ArtStyle sampleImageUrl?: string | null } @@ -75,12 +75,41 @@ export const DIRECTIONAL_MOVEMENT: Record = { 'eight-way': '八向', } +/** 后端 game_style;只有 pixel 会改变出帧管线,其余只进提示词。 */ +export type ArtStyle = 'pixel' | 'cartoon' | 'hand_drawn' | 'realistic' | 'unspecified' + +export const ART_STYLE: Record = { + pixel: '像素', + cartoon: '卡通', + hand_drawn: '手绘', + realistic: '写实', + unspecified: '不指定', +} + +/** 选项旁的一句说明;用户选画风时看不到管线,得把差别讲出来。 */ +export const ART_STYLE_HINT: Record = { + pixel: '出帧吸附母版像素网格,颜色吸回母版色板', + cartoon: '粗描线、平涂', + hand_drawn: '有笔触与纸纹', + realistic: '无描线,走渐变与体积光', + unspecified: '不给模型画风约束', +} + +export const ART_STYLE_OPTIONS: ArtStyle[] = [ + 'pixel', + 'cartoon', + 'hand_drawn', + 'realistic', + 'unspecified', +] + /** Project 对应的一组后端接口。 */ export interface ProjectApis { list(query?: ProjectPageQuery): Promise> get(id: Project['id']): Promise create(input: CreateProjectInput): Promise rename(id: Project['id'], name: string): Promise + setGameStyle(id: Project['id'], gameStyle: ArtStyle): Promise remove(id: Project['id']): Promise } @@ -99,6 +128,15 @@ interface ProjectDto { update_at: string } +export function isArtStyle(value: unknown): value is ArtStyle { + return typeof value === 'string' && value in ART_STYLE +} + +/** 后端已经把存量自由文本归一成枚举;这里只兜住 null 与未知取值。 */ +function artStyleFromDto(value: string | null): ArtStyle { + return isArtStyle(value) ? value : 'unspecified' +} + const perspectiveFromDto: Record = { 1: 'side', 2: 'top-down', @@ -154,7 +192,7 @@ function mapProject(dto: ProjectDto): Project { 'directional_movement', ), spriteSize: { width: dto.sprite_width, height: dto.sprite_height }, - gameStyle: dto.game_style, + gameStyle: artStyleFromDto(dto.game_style), sampleImageUrl: dto.sprite_sample_url, previewUrl: dto.preview_url ?? dto.sprite_sample_url, createdAt: dto.create_at, @@ -240,6 +278,15 @@ export const projectApis: ProjectApis = { } }, + async setGameStyle(id, gameStyle) { + return mapProject( + await getApiClient().request(`/projects/${encodeURIComponent(id)}`, { + method: 'PATCH', + json: { game_style: gameStyle }, + }), + ) + }, + async remove(id) { try { await getApiClient().request(`/projects/${encodeURIComponent(id)}`, { diff --git a/frontend/src/features/export-package/character-export.test.ts b/frontend/src/features/export-package/character-export.test.ts index 80401c7b0..04492fe9d 100644 --- a/frontend/src/features/export-package/character-export.test.ts +++ b/frontend/src/features/export-package/character-export.test.ts @@ -11,7 +11,7 @@ const project: Project = { perspective: 'side', directionalMovement: 'single', spriteSize: { width: 64, height: 80 }, - gameStyle: null, + gameStyle: 'unspecified', sampleImageUrl: null, createdAt: '2026-08-01T08:00:00Z', updatedAt: '2026-08-01T08:00:00Z', diff --git a/frontend/src/features/export-package/progressive-export.test.ts b/frontend/src/features/export-package/progressive-export.test.ts index 2412ebd33..2cc6ed868 100644 --- a/frontend/src/features/export-package/progressive-export.test.ts +++ b/frontend/src/features/export-package/progressive-export.test.ts @@ -11,7 +11,7 @@ const project: Project = { perspective: 'side', directionalMovement: 'single', spriteSize: { width: 32, height: 40 }, - gameStyle: null, + gameStyle: 'unspecified', sampleImageUrl: null, createdAt: '2026-08-01T08:00:00Z', updatedAt: '2026-08-01T08:00:00Z', diff --git a/frontend/src/features/quick-start-agent/production.ts b/frontend/src/features/quick-start-agent/production.ts index ddd60a981..2da19d22b 100644 --- a/frontend/src/features/quick-start-agent/production.ts +++ b/frontend/src/features/quick-start-agent/production.ts @@ -1,4 +1,5 @@ import { + isArtStyle, createAuthenticatedGenerationApis, projectApis, workflowRunApis, @@ -133,6 +134,7 @@ export function createProductionQuickStartAgentDependencies( return planner(input) }, async startCharacterGeneration(input) { + const gameStyle = isArtStyle(input.gameStyle) ? input.gameStyle : undefined generationApis ??= createAuthenticatedGenerationApis() const controller = createController({ workflowRunApis: runApis, @@ -141,7 +143,7 @@ export function createProductionQuickStartAgentDependencies( onAsyncError: reportError, }) try { - return await controller.startCharacterGeneration(input) + return await controller.startCharacterGeneration({ prompt: input.prompt, gameStyle }) } finally { controller.dispose() } diff --git a/frontend/src/features/quick-start-agent/react.ts b/frontend/src/features/quick-start-agent/react.ts index f6651759b..e29238004 100644 --- a/frontend/src/features/quick-start-agent/react.ts +++ b/frontend/src/features/quick-start-agent/react.ts @@ -145,6 +145,7 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) { async ( prompt: string, directionalMovement: QuickStartDirectionalMovement = 'single', + options?: { gameStyle?: string }, ): Promise => { if (running.current) throw new Error('Planner 正在处理上一条输入') if (state.status !== 'proposal') throw new Error('提示词提案已失效') @@ -159,7 +160,7 @@ export function useQuickStartAgent(options: UseQuickStartAgentOptions) { }) } try { - return await ensureAgent().confirmProposal(proposalId, prompt, directionalMovement) + return await ensureAgent().confirmProposal(proposalId, prompt, directionalMovement, options) } catch (cause) { if (mounted.current) { logAgentFailure(cause) diff --git a/frontend/src/features/quick-start-agent/runtime.ts b/frontend/src/features/quick-start-agent/runtime.ts index ec6eab9c4..939deb3af 100644 --- a/frontend/src/features/quick-start-agent/runtime.ts +++ b/frontend/src/features/quick-start-agent/runtime.ts @@ -67,6 +67,11 @@ export interface QuickStartAgentTurnOptions { signal?: AbortSignal } +export interface ConfirmProposalOptions { + /** 画风原样透传给宿主;取值范围由宿主定义,本模块不认识业务对象。 */ + gameStyle?: string +} + export interface QuickStartAgent { start(input: string, options?: QuickStartAgentTurnOptions): Promise continue(input: string, options?: QuickStartAgentTurnOptions): Promise @@ -74,6 +79,7 @@ export interface QuickStartAgent { proposalId: string, prompt: string, directionalMovement?: QuickStartDirectionalMovement, + options?: ConfirmProposalOptions, ): Promise revoke(): void } @@ -82,6 +88,7 @@ export interface QuickStartAgent { export type StartCharacterGenerationAction = (input: { prompt: string directionalMovement?: QuickStartDirectionalMovement + gameStyle?: string }) => Promise<{ runId: string }> export interface CreateQuickStartAgentOptions { @@ -283,6 +290,7 @@ export function createQuickStartAgent({ proposalId: string, prompt: string, directionalMovement: QuickStartDirectionalMovement = 'single', + { gameStyle }: ConfirmProposalOptions = {}, ): Promise { assertAuthorized() if (running) throw new Error('Planner 正在处理上一条输入') @@ -302,6 +310,7 @@ export function createQuickStartAgent({ const { runId } = await startCharacterGeneration({ prompt: effectivePrompt, directionalMovement, + gameStyle, }) return { kind: 'generated', runId, ...proposal, optimizedPrompt: effectivePrompt } } finally { diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index ffb23bb85..7a7a3d293 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -280,7 +280,9 @@ describe('WorkflowController', () => { controller.startCharacterGeneration({ prompt: ' 银发像素骑士 ' }), ).resolves.toEqual({ runId: 'run-1' }) - expect(prepareProject).toHaveBeenCalledWith('银发像素骑士', 'single') + expect(prepareProject).toHaveBeenCalledWith('银发像素骑士', 'single', { + gameStyle: undefined, + }) expect(workflow.apis.create).toHaveBeenCalledWith({ projectId: 'project-agent', nodes: [ @@ -327,7 +329,9 @@ describe('WorkflowController', () => { directionalMovement: 'four-way', }) - expect(prepareProject).toHaveBeenCalledWith('四向像素骑士', 'four-way') + expect(prepareProject).toHaveBeenCalledWith('四向像素骑士', 'four-way', { + gameStyle: undefined, + }) expect(generation.apis.create).toHaveBeenCalledTimes(1) expect(vi.mocked(generation.apis.create).mock.calls.map(([input]) => input.direction)).toEqual([ 'east', diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index c587b913d..d86732de2 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -30,6 +30,7 @@ import type { DirectionalMovement, } from '@/entities' import { + type ArtStyle, getDirectionProfile, isImageCandidateCount, ProjectNameConflictError, @@ -106,14 +107,20 @@ export interface ApplyGenerationResultInput { generation: Generation } +export interface PrepareQuickStartProjectOptions { + gameStyle?: ArtStyle +} + export type PrepareQuickStartProject = ( prompt: string, directionalMovement?: DirectionalMovement, + options?: PrepareQuickStartProjectOptions, ) => Promise & Partial>> export interface StartCharacterGenerationInput { prompt: string directionalMovement?: DirectionalMovement + gameStyle?: ArtStyle } export interface StartCharacterGenerationResult { @@ -250,7 +257,7 @@ function boundedProjectName(value: string, maxLength: number): string { export function createAutoPrepareProject( projectApis: Pick, ): PrepareQuickStartProject { - return async (prompt, directionalMovement = 'single') => { + return async (prompt, directionalMovement = 'single', options) => { const normalizedPrompt = prompt.trim().replace(/\s+/gu, ' ') || '未命名项目' let lastConflict: unknown @@ -265,6 +272,7 @@ export function createAutoPrepareProject( perspective: 'side', directionalMovement, spriteSize: { width: 256, height: 256 }, + gameStyle: options?.gameStyle, }) return { id: project.id, @@ -395,13 +403,16 @@ export function createWorkflowController({ async function startCharacterGeneration({ prompt, directionalMovement: selectedDirectionalMovement = 'single', + gameStyle, }: StartCharacterGenerationInput): Promise { ensureRunning() if (!prepareProject) { throw new Error('WorkflowController 未配置 Quick Start 项目准备能力') } const normalizedPrompt = nonEmpty(prompt, '角色描述') - const project = await prepareProject(normalizedPrompt, selectedDirectionalMovement) + const project = await prepareProject(normalizedPrompt, selectedDirectionalMovement, { + gameStyle, + }) generationDirections = getDirectionProfile( project.directionalMovement ?? selectedDirectionalMovement, ).generationDirections diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index a2f1f5d08..801b0e056 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -6,6 +6,7 @@ export type { GenerateActionOptions, GenerateCharacterTemplateOptions, PrepareQuickStartProject, + PrepareQuickStartProjectOptions, StartCharacterGenerationInput, StartCharacterGenerationResult, WorkflowController, diff --git a/frontend/src/pages/project-create/index.test.tsx b/frontend/src/pages/project-create/index.test.tsx index 694932d66..8b4aafa93 100644 --- a/frontend/src/pages/project-create/index.test.tsx +++ b/frontend/src/pages/project-create/index.test.tsx @@ -173,7 +173,7 @@ describe('ProjectCreatePage', () => { fireEvent.change(screen.getByLabelText('游戏视角'), { target: { value: 'top-down' } }) fireEvent.change(screen.getByLabelText('朝向'), { target: { value: 'eight-way' } }) fireEvent.click(screen.getByRole('button', { name: '512 × 512' })) - fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } }) + fireEvent.change(screen.getByLabelText('画风'), { target: { value: 'pixel' } }) fireEvent.click(screen.getByRole('button', { name: '创建项目' })) expect(await screen.findByRole('heading', { name: '雾港来信' })).toBeTruthy() @@ -186,7 +186,7 @@ describe('ProjectCreatePage', () => { directional_movement: 3, sprite_width: 512, sprite_height: 512, - game_style: '低饱和像素绘本', + game_style: 'pixel', }) // 归属由后端从 JWT 取;请求体再带 user_id 就等于宣称可以替别人建项目。 expect(Object.hasOwn(body, 'user_id')).toBe(false) @@ -217,11 +217,11 @@ describe('ProjectCreatePage', () => { await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '点灯人 · MVP' } }) - fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } }) + fireEvent.change(screen.getByLabelText('画风'), { target: { value: 'pixel' } }) fireEvent.click(screen.getByRole('button', { name: '创建项目' })) expect((await screen.findByRole('alert')).textContent).toBe('项目名称已存在') - expect((screen.getByLabelText('画风约束') as HTMLTextAreaElement).value).toBe('低饱和像素绘本') + expect((screen.getByLabelText('画风') as HTMLSelectElement).value).toBe('pixel') }) it('连续点击创建只发出一次请求', async () => { diff --git a/frontend/src/pages/project-create/index.tsx b/frontend/src/pages/project-create/index.tsx index cebb692c1..d8057603c 100644 --- a/frontend/src/pages/project-create/index.tsx +++ b/frontend/src/pages/project-create/index.tsx @@ -2,10 +2,14 @@ import { useRef, useState, type FormEvent } from 'react' import { useNavigate, useSearchParams } from 'react-router' import { + ART_STYLE, + ART_STYLE_HINT, + ART_STYLE_OPTIONS, CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis, workflowRunApis, + type ArtStyle, type CharacterPerspective, type DirectionalMovement, type Project, @@ -31,12 +35,6 @@ const SPRITE_MAX = 2048 /** 常用档位只是快捷填充,用户仍可以填这三档之外的任意合法宽高。 */ const SPRITE_PRESETS = [128, 256, 512] -/** - * 画风约束的长度上限只存在于前端:后端 `game_style` 是没有长度约束的 Text。 - * 定这个数是为了让它维持在「一句风格描述」的量级,不是契约要求。 - */ -const GAME_STYLE_MAX_LENGTH = 240 - /** 创建真实项目;首页画布入口与项目中心的新建按钮共用这一页。 */ export function ProjectCreatePage() { const navigate = useNavigate() @@ -54,7 +52,7 @@ export function ProjectCreatePage() { const [directionalMovement, setDirectionalMovement] = useState('single') const [spriteWidth, setSpriteWidth] = useState('256') const [spriteHeight, setSpriteHeight] = useState('256') - const [gameStyle, setGameStyle] = useState('') + const [gameStyle, setGameStyle] = useState('unspecified') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [createdProject, setCreatedProject] = useState(null) @@ -88,7 +86,7 @@ export function ProjectCreatePage() { perspective, directionalMovement, spriteSize: { width, height }, - gameStyle: gameStyle.trim() || null, + gameStyle, }) if (opensWorkflowEditor) setCreatedProject(project) } @@ -264,18 +262,24 @@ export function ProjectCreatePage() {
-