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.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', + }, + }) +} 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' diff --git a/frontend/src/features/export-package/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx index 415475947..b2ce062a7 100644 --- a/frontend/src/features/export-package/export-panel.tsx +++ b/frontend/src/features/export-package/export-panel.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from 'react' +import { useId, useState, type ReactNode } from 'react' import { ArrowClockwise, Check, CircleNotch, DownloadSimple } from '@phosphor-icons/react' import type { ExportPackageModel } from './model' @@ -24,6 +24,7 @@ export interface ExportButtonProps { model: ExportPackageModel exporter?: AssetExporter className?: string + icon?: ReactNode idleLabel?: string pill?: boolean iconOnly?: boolean @@ -148,6 +149,7 @@ export function ExportButton({ model, exporter = defaultExporter, className = '', + icon, idleLabel, pill = false, iconOnly = false, @@ -175,7 +177,7 @@ export function ExportButton({ className={ iconOnly ? `group/export-action relative grid size-10 shrink-0 place-items-center rounded-lg border border-current transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-accent disabled:cursor-not-allowed disabled:opacity-50 ${className}` - : `${pill ? 'rounded-full' : 'rounded-lg'} border border-current px-3 py-2 text-xs font-semibold disabled:opacity-50 ${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}` } > {iconOnly ? ( @@ -198,7 +200,14 @@ export function ExportButton({ ) : ( - label + <> + {icon ? ( + + ) : null} + {label} + )} {state.status === 'failure' ? ( 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 ( + + ) +} 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.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 2b86c479c..689919ee6 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 directions = within(sequence).getByRole('group', { name: '行走方向' }) expect( within(directions) @@ -124,9 +138,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('offers a mirrored west preview for legacy actions with only top-level frames', async () => { @@ -341,6 +372,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() }) }) diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx index 5f285c302..873b5df25 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 { @@ -18,7 +18,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: '行走', @@ -27,6 +27,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' + const DIRECTION_LABELS: Record = { east: '东', west: '西', @@ -97,6 +100,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 @@ -109,6 +113,7 @@ export function CharacterDetailPage() { setCharacter(null) setError(null) + setPixelPerfectOpen(false) void characterApis.get(characterId).then( (nextCharacter) => { if (!active) return @@ -142,11 +147,12 @@ export function CharacterDetailPage() { const name = characterName(character) const selectedOutfit = character.outfits[0] ?? null + const titlePreviewUrl = selectedOutfit?.previewUrl ?? null const canPlaytest = selectedOutfit ? getOutfitPlayback(selectedOutfit).playable : false return (
-
+
-
-

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

+

{name}

+ {selectedOutfit && titlePreviewUrl ? ( + {`${name}的${selectedOutfit.name}预览`} + ) : null}
-
+
{selectedOutfit ? ( ) : null} + {selectedOutfit && canPlaytest ? ( + + ) : null} {selectedOutfit && canPlaytest ? ( +
) : ( <> -
- -
+ + {pixelPerfectOpen ? ( + + ) : null} )} @@ -233,85 +264,52 @@ 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 }) { +function CharacterTemplateDirections({ character }: { character: Character }) { const name = characterName(character) const directionalTemplates = resolvedCharacterTemplates(character.templates) + if (directionalTemplates.length === 0) return null return ( -
-
- {directionalTemplates.length === 0 ? ( -
- + {directionalTemplates.map((template, index) => ( +
+
+ {`${name}${DIRECTION_LABELS[template.direction]}方向母版`}
- ) : null} -
-

- {outfit.name} -

-

- {outfit.actions.length} 个动作 - {directionalTemplates.length > 0 ? ` · ${directionalTemplates.length} 个角色方向` : ''} -

-
-
- - {directionalTemplates.length > 0 ? ( -
- {directionalTemplates.map((template, index) => ( -
-
- {`${name}${DIRECTION_LABELS[template.direction]}方向母版`} -
-
- {DIRECTION_LABELS[template.direction]} -
-
- ))} -
- ) : null} +
+ {DIRECTION_LABELS[template.direction]} +
+ + ))}
) } function ActionList({ character, outfit }: { character: Character; outfit: Outfit }) { const [selectedActionId, setSelectedActionId] = useState(null) - const [selectedDirection, setSelectedDirection] = useState('east') + const [revealedActionId, setRevealedActionId] = useState(null) const [entryOpen, setEntryOpen] = useState(false) - const selectedAction = outfit.actions.find((action) => action.id === selectedActionId) ?? null - const selectedActionDirections = selectedAction ? actionDirections(selectedAction) : [] - const activeDirection = selectedActionDirections.includes(selectedDirection) - ? selectedDirection - : (selectedActionDirections[0] ?? 'east') - const selectedActionFrames = selectedAction - ? actionFrames(selectedAction, activeDirection) - : { frames: [], mirrorX: false } + 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, @@ -325,11 +323,8 @@ function ActionList({ character, outfit }: { character: Character; outfit: Outfi const workflowEditorPath = `/workflow-editor/${encodeURIComponent(character.workflowRunId)}` return ( -
-
-

- 动作与帧 -

+
+
点击卡片展开完整帧 + {rendersFramePanel ? ( + + ) : null} ) })}
+ + )} +
+ ) +} + +function ActionFramePanel({ action, expanded }: { action: Action; expanded: boolean }) { + const directions = actionDirections(action) + const [selectedDirection, setSelectedDirection] = useState( + directions[0] ?? 'east', + ) + const activeDirection = directions.includes(selectedDirection) + ? selectedDirection + : (directions[0] ?? 'east') + const { frames, mirrorX } = actionFrames(action, activeDirection) - {selectedAction ? ( -
+
+
+

帧序列

+

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

+
+ {frames.length} 帧 +
+ {directions.length > 1 ? ( +
+ 选择动作方向 + {directions.map((direction) => ( +
+ ) : null} +
+
    + {frames.map((frame) => ( +
  1. +
    + {`${action.name}${DIRECTION_LABELS[activeDirection]}方向第
    -
    -
    -
    帧序列
    - - {selectedActionFrames.frames.length} 帧 - -
    - {selectedActionDirections.length > 1 ? ( -
    - 选择动作方向 - {selectedActionDirections.map((direction) => ( - - ))} -
    - ) : null} -
    -
      - {selectedActionFrames.frames.map((frame) => ( -
    1. -
      - {`${selectedAction.name}${DIRECTION_LABELS[activeDirection]}方向第 -
      -
      - #{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} - - )} + + ))} + +
) }