From 43a13a8f169ff5ef99f0b6ec59839defec9614f0 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 15:48:58 +0800 Subject: [PATCH 1/5] feat(pixel-perfect): add frontend processing client The asset workspace needs authenticated access to the binary pixel-perfect tool. Add a typed client that downloads source media and validates PNG responses and metadata. Frontend features can process existing asset images without duplicating transport logic. --- frontend/src/entities/index.ts | 9 + frontend/src/entities/pixel-perfect/api.ts | 224 +++++++++++++++++++ frontend/src/entities/pixel-perfect/index.ts | 24 ++ 3 files changed, 257 insertions(+) create mode 100644 frontend/src/entities/pixel-perfect/api.ts create mode 100644 frontend/src/entities/pixel-perfect/index.ts diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index ea74d900e..beb339bf4 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -122,6 +122,15 @@ export type { export { createMediaApis } from './media/api' export type { MediaApis, MediaCategory, MediaReference } from './media' +/* 完美像素化 —— 匿名读取素材,调用本地二进制工具并返回检测结果。 */ +export { createPixelPerfectApis, pixelPerfectApis } from './pixel-perfect' +export type { + PixelPerfectApis, + PixelPerfectMetadata, + PixelPerfectProcessInput, + PixelPerfectResult, +} from './pixel-perfect' + /* 工作流 —— 前端管理节点,后端只持久化完整 nodes 文档 */ export { WorkflowRunConflictError, workflowRunApis } from './workflow-run' export type { diff --git a/frontend/src/entities/pixel-perfect/api.ts b/frontend/src/entities/pixel-perfect/api.ts new file mode 100644 index 000000000..32c63bb5d --- /dev/null +++ b/frontend/src/entities/pixel-perfect/api.ts @@ -0,0 +1,224 @@ +import { + ApiError, + getApiAccessToken, + recoverApiUnauthorized, + resolveApiBaseUrl, +} from '@/shared/api' + +import type { + PixelPerfectApis, + PixelPerfectMetadata, + PixelPerfectProcessInput, + PixelPerfectResult, +} from '.' + +const PIXEL_PERFECT_PATH = '/tools/pixel-perfect' +const OUTPUT_FILENAME = 'pixel-perfect.png' + +interface ErrorEnvelope { + code: number + message: string + data: unknown +} + +export function createPixelPerfectApis(fetchFn: typeof fetch = globalThis.fetch): PixelPerfectApis { + return { + async process({ imageUrl }: PixelPerfectProcessInput): Promise { + const sourceFile = await downloadSourceImage(fetchFn, imageUrl) + return sendPixelPerfectRequest(fetchFn, sourceFile) + }, + } +} + +async function downloadSourceImage(fetchFn: typeof fetch, imageUrl: string): Promise { + let response: Response + try { + // 素材地址可能指向第三方对象存储;这里明确不携带 Windup token 或浏览器凭据。 + response = await fetchFn(imageUrl, { credentials: 'omit' }) + } catch (cause) { + throw new ApiError('源图下载失败', { kind: 'network', cause }) + } + + if (!response.ok) { + throw new ApiError(`源图下载失败(HTTP ${response.status})`, { + kind: 'http', + status: response.status, + }) + } + + const contentType = normalizeContentType(response.headers.get('content-type')) + if (contentType !== 'image/png' && contentType !== 'image/jpeg') { + throw new ApiError('源图必须是 PNG 或 JPEG 图片', { + kind: 'invalid-response', + status: response.status, + }) + } + + const blob = await response.blob() + return new File([blob], sourceFilename(imageUrl, contentType), { type: contentType }) +} + +async function sendPixelPerfectRequest( + fetchFn: typeof fetch, + file: File, + replayed = false, +): Promise { + const formData = new FormData() + formData.append('file', file) + const headers = new Headers() + const accessToken = getApiAccessToken() + if (accessToken) headers.set('authorization', `Bearer ${accessToken}`) + + let response: Response + try { + response = await fetchFn(`${resolveApiBaseUrl()}${PIXEL_PERFECT_PATH}`, { + method: 'POST', + headers, + body: formData, + credentials: 'include', + }) + } catch (cause) { + throw new ApiError('完美像素请求失败', { kind: 'network', cause }) + } + + const contentType = normalizeContentType(response.headers.get('content-type')) + if (contentType === 'application/json') { + const envelope = await readErrorEnvelope(response) + if (!replayed && envelope.code === 401 && (await recoverApiUnauthorized())) { + return sendPixelPerfectRequest(fetchFn, file, true) + } + if (!response.ok) { + throw new ApiError(envelope.message || '完美像素请求失败', { + kind: 'http', + status: response.status, + data: envelope.data, + }) + } + throw new ApiError(envelope.message, { + kind: 'business', + code: envelope.code, + status: response.status, + data: envelope.data, + }) + } + + if (!replayed && response.status === 401 && (await recoverApiUnauthorized())) { + return sendPixelPerfectRequest(fetchFn, file, true) + } + if (!response.ok) { + throw new ApiError(`完美像素请求失败(HTTP ${response.status})`, { + kind: 'http', + status: response.status, + }) + } + if (contentType !== 'image/png') { + throw new ApiError('完美像素接口没有返回 PNG 图片', { + kind: 'invalid-response', + status: response.status, + }) + } + + const metadata = parseMetadata(response.headers, response.status) + return { + blob: await response.blob(), + filename: responseFilename(response.headers.get('content-disposition')), + metadata, + } +} + +async function readErrorEnvelope(response: Response): Promise { + let value: unknown + try { + value = await response.json() + } catch (cause) { + throw new ApiError('完美像素接口返回的错误信息无效', { + kind: 'invalid-response', + status: response.status, + cause, + }) + } + if ( + !isRecord(value) || + typeof value.code !== 'number' || + typeof value.message !== 'string' || + !Object.hasOwn(value, 'data') + ) { + throw new ApiError('完美像素接口返回的错误信息无效', { + kind: 'invalid-response', + status: response.status, + data: value, + }) + } + return value as unknown as ErrorEnvelope +} + +function parseMetadata(headers: Headers, status: number): PixelPerfectMetadata { + const cols = positiveInteger(headers.get('x-pixel-cols')) + const rows = positiveInteger(headers.get('x-pixel-rows')) + const stepX = positiveNumber(headers.get('x-pixel-step-x')) + const stepY = positiveNumber(headers.get('x-pixel-step-y')) + const consensus = nonEmptyHeader(headers.get('x-pixel-consensus')) + const confidence = nonEmptyHeader(headers.get('x-pixel-confidence')) + if ( + cols === null || + rows === null || + stepX === null || + stepY === null || + consensus === null || + confidence === null + ) { + throw new ApiError('完美像素接口返回的检测信息无效', { + kind: 'invalid-response', + status, + }) + } + return { cols, rows, stepX, stepY, consensus, confidence } +} + +function normalizeContentType(value: string | null): string { + return value?.split(';', 1)[0]?.trim().toLowerCase() ?? '' +} + +function positiveInteger(value: string | null): number | null { + if (value === null || !/^\d+$/u.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +function positiveNumber(value: string | null): number | null { + if (value === null || value.trim() === '') return null + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function nonEmptyHeader(value: string | null): string | null { + const normalized = value?.trim() + return normalized ? normalized : null +} + +function sourceFilename(imageUrl: string, contentType: 'image/png' | 'image/jpeg'): string { + let name = '' + try { + name = decodeURIComponent( + new URL(imageUrl, 'http://windup.local').pathname.split('/').at(-1) ?? '', + ) + } catch { + // URL 无法解析时仍可按已验证的媒体类型生成安全文件名。 + } + const stem = name.replace(/\.(?:png|jpe?g)$/iu, '').replace(/[^\w.-]+/gu, '-') || 'source' + return `${stem}${contentType === 'image/png' ? '.png' : '.jpg'}` +} + +function responseFilename(contentDisposition: string | null): string { + const match = contentDisposition?.match(/filename\s*=\s*"?([^";]+)"?/iu) + const name = match?.[1]?.trim().split(/[\\/]/u).at(-1) + return name || OUTPUT_FILENAME +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export const pixelPerfectApis: PixelPerfectApis = { + process: (input) => createPixelPerfectApis().process(input), +} diff --git a/frontend/src/entities/pixel-perfect/index.ts b/frontend/src/entities/pixel-perfect/index.ts new file mode 100644 index 000000000..50cf2b312 --- /dev/null +++ b/frontend/src/entities/pixel-perfect/index.ts @@ -0,0 +1,24 @@ +export interface PixelPerfectProcessInput { + imageUrl: string +} + +export interface PixelPerfectMetadata { + cols: number + rows: number + stepX: number + stepY: number + consensus: string + confidence: string +} + +export interface PixelPerfectResult { + blob: Blob + filename: string + metadata: PixelPerfectMetadata +} + +export interface PixelPerfectApis { + process(input: PixelPerfectProcessInput): Promise +} + +export { createPixelPerfectApis, pixelPerfectApis } from './api' From dc5eb00fd54b251186cf7c1a00797875616f6555 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 15:54:28 +0800 Subject: [PATCH 2/5] test(pixel-perfect): cover binary processing client Binary image transport needs regression coverage for authentication and response validation. Cover source downloads, token recovery, business failures, metadata checks, and missing media. The frontend client now has focused evidence for its API boundary. --- .../src/entities/pixel-perfect/api.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 frontend/src/entities/pixel-perfect/api.test.ts diff --git a/frontend/src/entities/pixel-perfect/api.test.ts b/frontend/src/entities/pixel-perfect/api.test.ts new file mode 100644 index 000000000..8cd6704eb --- /dev/null +++ b/frontend/src/entities/pixel-perfect/api.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createPixelPerfectApis } from '@/entities' +import { registerApiAccessTokenProvider, registerApiUnauthorizedRecovery } from '@/shared/api' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('PixelPerfectApis.process', () => { + it('匿名读取源图,只把文件交给带当前登录态的完美像素接口', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + calls.push({ url, init }) + if (url === 'https://cdn.example.com/characters/scout.png') { + return imageResponse(['source'], 'image/png') + } + return processedResponse() + }) as typeof fetch + const unregister = registerApiAccessTokenProvider(() => 'access-token') + + try { + const result = await createPixelPerfectApis(fetchFn).process({ + imageUrl: 'https://cdn.example.com/characters/scout.png', + }) + + expect(await result.blob.text()).toBe('processed') + expect(result.filename).toBe('pixel-perfect.png') + expect(result.metadata).toEqual({ + cols: 24, + rows: 32, + stepX: 4.5, + stepY: 4, + consensus: 'arbitrated', + confidence: 'high', + }) + } finally { + unregister() + } + + expect(calls).toHaveLength(2) + const sourceRequest = new Request(calls[0]!.url, calls[0]!.init) + expect(sourceRequest.headers.has('authorization')).toBe(false) + expect(sourceRequest.credentials).toBe('omit') + + expect(calls[1]!.url).toBe('http://127.0.0.1:8000/tools/pixel-perfect') + const apiRequest = new Request(calls[1]!.url, calls[1]!.init) + expect(apiRequest.method).toBe('POST') + expect(apiRequest.credentials).toBe('include') + expect(apiRequest.headers.get('authorization')).toBe('Bearer access-token') + expect(new Headers(calls[1]!.init?.headers).has('content-type')).toBe(false) + + const formData = calls[1]!.init?.body as FormData + expect([...formData.keys()]).toEqual(['file']) + const file = formData.get('file') + expect(file).toBeInstanceOf(File) + expect(file).toMatchObject({ name: 'scout.png', type: 'image/png' }) + expect(await (file as File).text()).toBe('source') + }) + + it('登录态过期时恢复会话并以新 token 重放二进制请求', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + let accessToken = 'expired-token' + const apiAuthorizations: Array = [] + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === 'https://cdn.example.com/scout.png') { + return imageResponse(['source'], 'image/png') + } + apiAuthorizations.push(new Headers(init?.headers).get('authorization')) + if (apiAuthorizations.length === 1) { + return businessResponse(401, '登录状态已过期') + } + return processedResponse() + }) as typeof fetch + const unregisterToken = registerApiAccessTokenProvider(() => accessToken) + const unregisterRecovery = registerApiUnauthorizedRecovery(async () => { + accessToken = 'renewed-token' + return true + }) + + try { + await expect( + createPixelPerfectApis(fetchFn).process({ + imageUrl: 'https://cdn.example.com/scout.png', + }), + ).resolves.toMatchObject({ filename: 'pixel-perfect.png' }) + } finally { + unregisterRecovery() + unregisterToken() + } + + expect(apiAuthorizations).toEqual(['Bearer expired-token', 'Bearer renewed-token']) + }) + + it('把 HTTP 200 JSON 中的业务失败作为用户可读错误抛出', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchFn = vi + .fn() + .mockResolvedValueOnce(imageResponse(['source'], 'image/png')) + .mockResolvedValueOnce(businessResponse(400, '图片不能超过 10 MB')) as typeof fetch + + await expect( + createPixelPerfectApis(fetchFn).process({ imageUrl: 'https://cdn.example.com/large.png' }), + ).rejects.toMatchObject({ + name: 'ApiError', + kind: 'business', + code: 400, + status: 200, + message: '图片不能超过 10 MB', + }) + }) + + it('拒绝伪装成成功的非 PNG 响应', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchFn = vi + .fn() + .mockResolvedValueOnce(imageResponse(['source'], 'image/png')) + .mockResolvedValueOnce( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }), + ) as typeof fetch + + await expect( + createPixelPerfectApis(fetchFn).process({ imageUrl: 'https://cdn.example.com/scout.png' }), + ).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + message: '完美像素接口没有返回 PNG 图片', + }) + }) + + it('拒绝缺少检测元数据的 PNG 响应', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchFn = vi + .fn() + .mockResolvedValueOnce(imageResponse(['source'], 'image/png')) + .mockResolvedValueOnce(imageResponse(['processed'], 'image/png')) as typeof fetch + + await expect( + createPixelPerfectApis(fetchFn).process({ imageUrl: 'https://cdn.example.com/scout.png' }), + ).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + message: '完美像素接口返回的检测信息无效', + }) + }) + + it('源图无法下载时不调用完美像素接口', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://127.0.0.1:8000') + const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 404 })) as typeof fetch + + await expect( + createPixelPerfectApis(fetchFn).process({ imageUrl: 'https://cdn.example.com/missing.png' }), + ).rejects.toThrow('源图下载失败(HTTP 404)') + expect(fetchFn).toHaveBeenCalledTimes(1) + }) +}) + +function imageResponse(parts: BlobPart[], type: string): Response { + return new Response(new Blob(parts, { type }), { + status: 200, + headers: { 'content-type': type }, + }) +} + +function businessResponse(code: number, message: string): Response { + return new Response(JSON.stringify({ code, message, data: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function processedResponse(): Response { + return new Response(new Blob(['processed'], { type: 'image/png' }), { + status: 200, + headers: { + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="pixel-perfect.png"', + 'x-pixel-cols': '24', + 'x-pixel-rows': '32', + 'x-pixel-step-x': '4.5', + 'x-pixel-step-y': '4', + 'x-pixel-consensus': 'arbitrated', + 'x-pixel-confidence': 'high', + }, + }) +} From 3419d572341f37edf946304ce0f8e85a8ac38752 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 15:54:48 +0800 Subject: [PATCH 3/5] feat(pixel-perfect): add action frame workbench Character assets need a focused surface for processing existing action frames. Add single-action and all-action processing with comparison, progress, failure, and download states. Users can inspect pixel-perfect results without replacing stored assets. --- frontend/src/features/pixel-perfect/index.ts | 2 + .../pixel-perfect/pixel-perfect-workbench.tsx | 408 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 frontend/src/features/pixel-perfect/index.ts create mode 100644 frontend/src/features/pixel-perfect/pixel-perfect-workbench.tsx diff --git a/frontend/src/features/pixel-perfect/index.ts b/frontend/src/features/pixel-perfect/index.ts new file mode 100644 index 000000000..e8b103edb --- /dev/null +++ b/frontend/src/features/pixel-perfect/index.ts @@ -0,0 +1,2 @@ +export { PixelPerfectWorkbench } from './pixel-perfect-workbench' +export type { PixelPerfectWorkbenchProps } from './pixel-perfect-workbench' diff --git a/frontend/src/features/pixel-perfect/pixel-perfect-workbench.tsx b/frontend/src/features/pixel-perfect/pixel-perfect-workbench.tsx new file mode 100644 index 000000000..8626b4521 --- /dev/null +++ b/frontend/src/features/pixel-perfect/pixel-perfect-workbench.tsx @@ -0,0 +1,408 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { DownloadSimple, GridFour, Stack } from '@phosphor-icons/react' + +import type { Action } from '@/entities' +import { pixelPerfectApis } from '@/entities/pixel-perfect' + +export interface PixelPerfectWorkbenchProps { + actions: Action[] + id?: string +} + +type ScopeMode = 'single' | 'all' +type FrameStatus = 'working' | 'success' | 'failure' + +interface FrameTarget { + key: string + actionId: string + actionName: string + frameIndex: number + imageUrl: string +} + +interface ActionGroup { + id: string + name: string + fps: number + frames: FrameTarget[] +} + +interface FrameResult { + filename: string + previewUrl: string +} + +type BatchState = + | { status: 'idle' } + | { status: 'working'; completed: number; total: number } + | { status: 'complete'; failed: number; total: number } + +function toActionGroups(actions: Action[]): ActionGroup[] { + return actions + .map((action) => ({ + id: action.id, + name: action.name, + fps: action.fps, + frames: [...action.frames] + .sort((left, right) => left.index - right.index) + .map((frame) => ({ + key: `${action.id}:${frame.index}`, + actionId: action.id, + actionName: action.name, + frameIndex: frame.index, + imageUrl: frame.imageUrl, + })), + })) + .filter((action) => action.frames.length > 0) +} + +export function PixelPerfectWorkbench({ actions, id }: PixelPerfectWorkbenchProps) { + const requestSequenceRef = useRef(0) + const resultUrlsRef = useRef(new Map()) + const groups = useMemo(() => toActionGroups(actions), [actions]) + const allFrames = useMemo(() => groups.flatMap((action) => action.frames), [groups]) + const [scope, setScope] = useState('single') + const [selectedActionId, setSelectedActionId] = useState(groups[0]?.id ?? '') + const [selectedFrameKey, setSelectedFrameKey] = useState(groups[0]?.frames[0]?.key ?? '') + const [results, setResults] = useState>({}) + const [frameStatuses, setFrameStatuses] = useState>({}) + const [frameErrors, setFrameErrors] = useState>({}) + const [batchState, setBatchState] = useState({ status: 'idle' }) + + const selectedAction = + groups.find((action) => action.id === selectedActionId) ?? groups[0] ?? null + const selectedFrame = + selectedAction?.frames.find((frame) => frame.key === selectedFrameKey) ?? + selectedAction?.frames[0] ?? + null + const working = batchState.status === 'working' + + useEffect(() => { + if (groups.some((action) => action.id === selectedActionId)) return + const firstAction = groups[0] + setSelectedActionId(firstAction?.id ?? '') + setSelectedFrameKey(firstAction?.frames[0]?.key ?? '') + }, [groups, selectedActionId]) + + useEffect( + () => () => { + requestSequenceRef.current += 1 + for (const url of resultUrlsRef.current.values()) URL.revokeObjectURL(url) + resultUrlsRef.current.clear() + }, + [], + ) + + function clearTargetResults(targets: FrameTarget[]) { + const keys = new Set(targets.map((frame) => frame.key)) + for (const [key, url] of resultUrlsRef.current) { + if (!keys.has(key)) continue + URL.revokeObjectURL(url) + resultUrlsRef.current.delete(key) + } + setResults((current) => { + const next = { ...current } + for (const key of keys) delete next[key] + return next + }) + setFrameStatuses((current) => { + const next = { ...current } + for (const key of keys) delete next[key] + return next + }) + setFrameErrors((current) => { + const next = { ...current } + for (const key of keys) delete next[key] + return next + }) + } + + async function processSelection() { + if (working || !selectedAction) return + const targets = scope === 'single' ? selectedAction.frames : allFrames + if (targets.length === 0) return + + const requestSequence = requestSequenceRef.current + 1 + requestSequenceRef.current = requestSequence + clearTargetResults(targets) + setBatchState({ status: 'working', completed: 0, total: targets.length }) + + let completed = 0 + let failed = 0 + for (const target of targets) { + if (requestSequenceRef.current !== requestSequence) return + setSelectedActionId(target.actionId) + setSelectedFrameKey(target.key) + setFrameStatuses((current) => ({ ...current, [target.key]: 'working' })) + try { + const result = await pixelPerfectApis.process({ imageUrl: target.imageUrl }) + if (requestSequenceRef.current !== requestSequence) return + const previewUrl = URL.createObjectURL(result.blob) + resultUrlsRef.current.set(target.key, previewUrl) + setResults((current) => ({ + ...current, + [target.key]: { filename: result.filename, previewUrl }, + })) + setFrameStatuses((current) => ({ ...current, [target.key]: 'success' })) + } catch (error) { + if (requestSequenceRef.current !== requestSequence) return + failed += 1 + setFrameStatuses((current) => ({ ...current, [target.key]: 'failure' })) + setFrameErrors((current) => ({ + ...current, + [target.key]: error instanceof Error ? error.message : '未知错误', + })) + } + completed += 1 + setBatchState({ status: 'working', completed, total: targets.length }) + } + setBatchState({ status: 'complete', failed, total: targets.length }) + } + + function selectAction(action: ActionGroup) { + setSelectedActionId(action.id) + setSelectedFrameKey(action.frames[0]?.key ?? '') + } + + function downloadSelectedResult() { + if (!selectedFrame) return + const result = results[selectedFrame.key] + if (!result) return + const anchor = document.createElement('a') + anchor.href = result.previewUrl + anchor.download = result.filename + anchor.click() + } + + if (!selectedAction || !selectedFrame) return null + + const selectedResult = results[selectedFrame.key] + const selectedStatus = frameStatuses[selectedFrame.key] + const selectedError = frameErrors[selectedFrame.key] + + return ( +
+
+
+ setScope('single')} + > + + setScope('all')}> + +
+ +
+ {groups.map((action) => { + const resultCount = action.frames.filter((frame) => results[frame.key]).length + return ( + + ) + })} +
+ + +
+ + {batchState.status === 'complete' ? ( +

+ {batchState.failed === 0 + ? `已完成 ${batchState.total} 帧` + : `完成 ${batchState.total - batchState.failed} 帧,${batchState.failed} 帧失败`} +

+ ) : null} + +
+ + +
+ +
+
+ {selectedAction.frames.map((frame) => ( + setSelectedFrameKey(frame.key)} + /> + ))} +
+ {selectedResult ? ( + + ) : null} +
+
+ ) +} + +function ScopeButton({ + pressed, + disabled, + onClick, + children, +}: { + pressed: boolean + disabled: boolean + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + +function ComparePane({ + label, + imageUrl, + imageAlt, + status, + error, +}: { + label: string + imageUrl: string | null + imageAlt: string + status?: FrameStatus + error?: string +}) { + return ( +
+
+ {label} +
+
+ {imageUrl ? ( + {imageAlt} + ) : ( +
+

+ {status === 'working' + ? '处理中…' + : status === 'failure' + ? `处理失败:${error ?? '未知错误'}` + : '等待处理'} +

+
+ )} +
+
+ ) +} + +function FrameChoice({ + frame, + selected, + status, + onClick, +}: { + frame: FrameTarget + selected: boolean + status?: FrameStatus + onClick: () => void +}) { + return ( + + ) +} From db771f4afeeab3ffc0f1aba3d10861ec99200f2d Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 15:55:12 +0800 Subject: [PATCH 4/5] feat(assets): refine character asset workspace Asset cards and dense action sequences need a consistent responsive hierarchy. Align library cards, restyle character actions, expand frames inline, and connect pixel processing. The asset workspace now scales across desktop widths without changing existing workflows. --- .../features/export-package/export-panel.tsx | 13 +- frontend/src/pages/asset-library/index.tsx | 6 +- frontend/src/pages/character-detail/index.tsx | 240 +++++++++--------- 3 files changed, 136 insertions(+), 123 deletions(-) diff --git a/frontend/src/features/export-package/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx index 82538ce62..315799286 100644 --- a/frontend/src/features/export-package/export-panel.tsx +++ b/frontend/src/features/export-package/export-panel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import type { ExportPackageModel } from './model' import { @@ -23,6 +23,7 @@ export interface ExportButtonProps { model: ExportPackageModel exporter?: AssetExporter className?: string + icon?: ReactNode idleLabel?: string pill?: boolean } @@ -146,6 +147,7 @@ export function ExportButton({ model, exporter = defaultExporter, className = '', + icon, idleLabel, pill = false, }: ExportButtonProps) { @@ -166,9 +168,14 @@ export function ExportButton({ disabled={working} title={state.status === 'failure' ? state.message : undefined} onClick={() => void startExport()} - className={`${pill ? 'rounded-full' : 'rounded-lg'} border border-current px-3 py-2 text-xs font-semibold disabled:opacity-50 ${className}`} + className={`${pill ? 'rounded-full' : 'rounded-lg'} inline-flex min-h-10 items-center justify-center gap-2 border border-current px-3 py-2 text-xs font-semibold disabled:opacity-50 ${className}`} > - {label} + {icon ? ( + + ) : null} + {label} {state.status === 'failure' ? ( diff --git a/frontend/src/pages/asset-library/index.tsx b/frontend/src/pages/asset-library/index.tsx index c61311704..46378ef11 100644 --- a/frontend/src/pages/asset-library/index.tsx +++ b/frontend/src/pages/asset-library/index.tsx @@ -51,7 +51,7 @@ export function AssetLibraryPage() { return (
-
+

{project.name}

@@ -103,7 +103,7 @@ function CharacterGrid({ if (characters.length === 0) return return ( -
+
{characters.map((character, index) => { const name = characterName(character) return ( diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx index 5218a0145..dc4c30c64 100644 --- a/frontend/src/pages/character-detail/index.tsx +++ b/frontend/src/pages/character-detail/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Graph, Lightning, Plus, X } from '@phosphor-icons/react' +import { DownloadSimple, Graph, GridFour, Lightning, Play, Plus, X } from '@phosphor-icons/react' import { Link, useOutletContext, useParams } from 'react-router' import { @@ -11,7 +11,7 @@ import { type Project, } from '@/entities' import { createCharacterExportModel, ExportButton } from '@/features/export-package' -import { AssetPreviewSurface } from '@/shared/ui' +import { PixelPerfectWorkbench } from '@/features/pixel-perfect' const ACTION_TYPE_LABELS: Record = { walk: '行走', @@ -20,6 +20,9 @@ const ACTION_TYPE_LABELS: Record = { custom: '自定义', } +const ASSET_ACTION_SECONDARY = + 'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-app-line bg-app-surface-raised px-5 text-xs font-semibold text-app-ink-soft transition-colors hover:border-app-line-strong hover:bg-app-surface-muted hover:text-app-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-accent' + function actionTypeLabel(type: string) { return ACTION_TYPE_LABELS[type] ?? type } @@ -37,6 +40,7 @@ export function CharacterDetailPage() { const project = useOutletContext() const [character, setCharacter] = useState(null) const [error, setError] = useState(null) + const [pixelPerfectOpen, setPixelPerfectOpen] = useState(false) useEffect(() => { let active = true @@ -49,6 +53,7 @@ export function CharacterDetailPage() { setCharacter(null) setError(null) + setPixelPerfectOpen(false) void characterApis.get(characterId).then( (nextCharacter) => { if (!active) return @@ -82,11 +87,12 @@ export function CharacterDetailPage() { const name = characterName(character) const selectedOutfit = character.outfits[0] ?? null + const titlePreviewUrl = selectedOutfit?.previewUrl ?? null const canPlaytest = selectedOutfit?.actions.some((action) => action.frames.length > 0) ?? false return (
-
+
-
-

- {project.name} / 角色资产 -

+

{name}

+ {selectedOutfit && titlePreviewUrl ? ( + {`${name}的${selectedOutfit.name}预览`} + ) : null}
-
+
{selectedOutfit ? ( ) : null} + {selectedOutfit && canPlaytest ? ( + + ) : null} {selectedOutfit && canPlaytest ? ( +
) : ( <> -
- -
+ {pixelPerfectOpen ? ( + + ) : null} )} @@ -173,43 +203,19 @@ function CharacterExport({ } pill - className="inline-flex min-h-10 items-center border-app-line px-5 text-xs font-semibold text-app-ink-soft transition-colors hover:border-app-line-strong hover:text-app-accent" + className={ASSET_ACTION_SECONDARY} /> ) } -function OutfitMaster({ character, outfit }: { character: Character; outfit: Outfit }) { - const name = characterName(character) - return ( -
-
- -
-
-

- {outfit.name} -

-

- {outfit.actions.length} 个动作 -

-
-
- ) -} - function ActionList({ character, outfit }: { character: Character; outfit: Outfit }) { const [selectedActionId, setSelectedActionId] = useState(null) + const [revealedActionId, setRevealedActionId] = useState(null) const [entryOpen, setEntryOpen] = useState(false) - const selectedAction = outfit.actions.find((action) => action.id === selectedActionId) ?? null + const displayedActions = outfit.actions + const selectedAction = displayedActions.find((action) => action.id === selectedActionId) ?? null const templateImages = characterTemplateImages(character.templates) const canCreateAction = Boolean( templateImages.east || outfit.previewUrl || character.referenceImageUrl, @@ -223,11 +229,8 @@ function ActionList({ character, outfit }: { character: Character; outfit: Outfi const workflowEditorPath = `/workflow-editor/${encodeURIComponent(character.workflowRunId)}` return ( -
-
-

- 动作与帧 -

+
+
点击卡片展开完整帧 + {rendersFramePanel ? ( + + ) : null} ) })}
+ + )} +
+ ) +} - {selectedAction ? ( -
-
-

- 当前动作 -

-

- {selectedAction.name} -

-
-
类型
-
{actionTypeLabel(selectedAction.type)}
-
帧率
-
{selectedAction.fps} FPS
-
帧数
-
{selectedAction.frameCount}
-
播放
-
{selectedAction.loop ? '循环' : '单次'}
-
+function ActionFramePanel({ action, expanded }: { action: Action; expanded: boolean }) { + return ( +
+
+
+

帧序列

+

+ {actionTypeLabel(action.type)} · {action.fps} FPS · {action.loop ? '循环' : '单次'} +

+
+ {action.frameCount} 帧 +
+
+
    + {orderedFrames(action).map((frame) => ( +
  1. +
    + {`${action.name}第
    -
    -
    -
    帧序列
    - - {selectedAction.frameCount} 帧 - -
    -
    -
      - {orderedFrames(selectedAction).map((frame) => ( -
    1. -
      - {`${selectedAction.name}第 -
      -
      - #{String(frame.index + 1).padStart(2, '0')} - - {frame.durationMs === null - ? `按 ${selectedAction.fps} FPS` - : `${frame.durationMs} ms`} - -
      -
    2. - ))} -
    -
    +
    + #{String(frame.index + 1).padStart(2, '0')} + + {frame.durationMs === null ? `按 ${action.fps} FPS` : `${frame.durationMs} ms`} +
    -
- ) : null} - - )} + + ))} + +
) } From 5018666a770014fa608a6cd7f0a298d75f0ffce4 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 15:55:32 +0800 Subject: [PATCH 5/5] test(assets): cover character detail interactions The refined asset detail must preserve existing routes while changing its visual structure. Cover action controls, inline frame expansion, vertical scrolling, and empty states. The page behavior remains reviewable independently from its presentation changes. --- .../src/pages/character-detail/index.test.tsx | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 37625d508..9be2db9e6 100644 --- a/frontend/src/pages/character-detail/index.test.tsx +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -54,14 +54,26 @@ describe('CharacterDetailPage', () => { } expect(screen.queryByText('GIF')).toBeNull() expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(false) - const exportEntry = screen.getByRole('button', { name: '导出资产包' }) + const assetActions = screen.getByRole('group', { name: '角色资产操作' }) + const exportEntry = within(assetActions).getByRole('button', { name: '导出资产包' }) expect(exportEntry.className).toContain('rounded-full') + const pixelPerfectEntry = within(assetActions).getByRole('button', { name: '完美像素化' }) + const playtestEntry = within(assetActions).getByRole('link', { + name: '在预览台打开当前造型', + }) + for (const action of [exportEntry, pixelPerfectEntry, playtestEntry]) { + expect(action.querySelector('svg')).toBeTruthy() + expect(action.className).toContain('min-h-10') + expect(action.className).toContain('rounded-full') + } + expect(screen.queryByRole('button', { name: '完美像素画' })).toBeNull() expect(screen.queryByText('当前阶段')).toBeNull() + expect(screen.queryByRole('heading', { name: '动作与帧' })).toBeNull() + expect(screen.getByRole('region', { name: '角色动作' })).toBeTruthy() expect(screen.queryByRole('dialog', { name: '导出资产包' })).toBeNull() expect(screen.queryByText('导出能力待 PR #97 合并并完成资产字段接线')).toBeNull() - const playtestEntry = screen.getByRole('link', { name: '在预览台打开当前造型' }) expect(playtestEntry.getAttribute('href')).toBe('/playtest/51/outfit-default') - expect(playtestEntry.parentElement?.className).toContain('items-start') + expect(playtestEntry.parentElement?.className).toContain('items-center') }) it('routes both add-action choices to the character existing WorkflowRun', async () => { @@ -96,6 +108,8 @@ describe('CharacterDetailPage', () => { fireEvent.click(screen.getByRole('button', { name: '展开行走' })) const sequence = screen.getByRole('region', { name: '行走完整帧序列' }) + const expandedCard = screen.getByRole('article', { name: '动作 行走' }) + expect(expandedCard.contains(sequence)).toBe(true) const frames = within(sequence).getAllByRole('img') expect(frames.map((frame) => frame.getAttribute('src'))).toEqual([ 'https://cdn.windup.test/walk-01.png', @@ -104,9 +118,26 @@ describe('CharacterDetailPage', () => { ]) expect(within(sequence).queryByRole('button', { name: '保存为动作模板' })).toBeNull() expect(screen.queryByText('动作模板后端未提供')).toBeNull() - const scroller = sequence.querySelector('.overflow-x-auto') + const scroller = sequence.querySelector('.overflow-y-auto') expect(scroller).toBeTruthy() - expect(scroller?.querySelector('ol')?.className).toContain('min-w-max') + expect(scroller?.className).not.toContain('overflow-x-auto') + expect(scroller?.querySelector('ol')?.className).toContain('auto-fill') + }) + + it('keeps the frame panel mounted while the selected card collapses', async () => { + renderCharacter('51') + + expect(await screen.findByRole('heading', { name: '轻装信使' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开行走' })) + const card = screen.getByRole('article', { name: '动作 行走' }) + expect(card.querySelector('[aria-label="行走完整帧序列"]')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '收起行走' })) + + const exitingPanel = card.querySelector('[aria-label="行走完整帧序列"]') + expect(exitingPanel).toBeTruthy() + expect(exitingPanel?.getAttribute('aria-hidden')).toBe('true') + expect(exitingPanel?.className).toContain('h-0') }) it('allows adding an action when the character has directional templates without an outfit preview', async () => { @@ -186,6 +217,7 @@ describe('CharacterDetailPage', () => { expect(await screen.findByText('这个角色还没有造型')).toBeTruthy() expect(screen.queryByRole('button', { name: '导出资产包' })).toBeNull() + expect(screen.queryByRole('button', { name: '完美像素化' })).toBeNull() expect(screen.queryByRole('link', { name: '在预览台打开当前造型' })).toBeNull() }) })