资源卡工具条接通「生成动画」:只放行真能跑通的角色图派生

- resourceCanvasToolbarModel:只有在「已登记的栅格角色图」上放行 character-animation
  (非角色 / 未登记为正式素材 / 非栅格三类都不放行;Rust 侧角色动画要解码源图取宽高,SVG 必失败);其余动作仍按 opt-in 不渲染,并写明不做它们各自的阻塞证据。
- index.tsx:把源解析与 revision 缓存抽成 resolveResourceDeriveSource,快速编辑与生成动画共用,避免同一个源在两条链路上得到两份 manifest 视图;新增生成动画的源/浮层状态、请求身份(与快速编辑同一套「提示词变了才重铸」口径)与打开/提交/关闭时机。
- 面板复用共享 ImageCanvasCharacterAnimationPanelView,参数入口显式关闭(比例/时长/清晰度与模型由客户端 Rust 固定为 same/720p/32 帧/4 秒/seedance2.0-fast,改它不改变请求);档位常量与 Rust 常量同值,泥点价按同一算法算,避免按钮上出现与实际计费不符的数字。
- 「改造」(redraw) 不再另起入口:其语义由既有「快速编辑」承担,接独立按钮只是同链路重复入口。
- 断言改写(四不写):角色资源那条由「只放行快速编辑与下载」改写为「放行生成动画」,并新增三条反向守卫(非角色图片 / 角色图未登记 / 非栅格角色图均不放行)。
- 端到端新增「从角色资源卡生成动画」:断言 editKind/generationMode/prompt/assetName/sourceAssetId 与泥点价档位,并断言参数入口不存在。
This commit is contained in:
2026-09-11 20:08:22 +08:00
parent 4be88f177f
commit e765c9f8a6
5 changed files with 549 additions and 68 deletions
@@ -109,10 +109,14 @@ export function resolveResourceCanvasToolbarActions(
resource: ProjectResource,
): ReadonlySet<ImageCanvasSelectedToolbarAction> {
// 判据只有一条:**放进来就必须真能跑通**。工具条按这个集合渲染按钮,未接通的动作
// 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通的只有「快速编辑」
// normalize → derive(editKind='image-reference')与「下载」(复用资源面板同一条
// 本地文件复制链路);「改造」(redraw) 与「角色动画」(character-animation) 在宿主
// 编排层仍是空回调,接上真实链路后再放回来
// 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通的「快速编辑」
// normalize → derive(editKind='image-reference')、「生成动画」
// derive(editKind='character-animation'),见 `index.tsx` 的
// `submitResourceCharacterAnimation`)与「下载」(复用资源面板同一条本地文件复制链路)
//
// 其余动作按本轮口径不做(改造 / 裁扩 / 去背景 / 完美像素 / 拆分图集 / 提取素材):
// 它们在后端各自缺 editKind,或不在 `resource_editor.rs` 的远端路由白名单里,接上只会
// 得到必然失败的请求或名不副实的按钮。「改造」的语义由「快速编辑」承担,不再另起入口。
const supported = new Set<ImageCanvasSelectedToolbarAction>();
const mediaType = resourceCanvasMediaType(resource);
const canDeriveFromResource =
@@ -121,6 +125,12 @@ export function resolveResourceCanvasToolbarActions(
if (mediaType === 'image') {
if (isResourceRasterImage(resource) && canDeriveFromResource) {
supported.add('quick-edit');
// 门禁与美术画布一致:只有角色类资源才出「生成动画」。Rust 侧
// `resolve_resource_edit_source` 对 CharacterAnimation 会拿 `image::load_from_memory`
// 解出源图宽高,所以非栅格图片(如 SVG)必须在这里就挡住,不能留给后端报错。
if (resource.subtype === 'character') {
supported.add('character-animation');
}
}
}
// 「下载」的判据不是媒体类型,而是"拿得到可落盘的本地文件路径":虚拟版本条目没有
File diff suppressed because it is too large Load Diff
@@ -194,6 +194,19 @@ export function defaultDerivedResourceName(resource: ProjectResource) {
return `${baseName || '资源'}-编辑版`;
}
/**
* 角色动画派生资源的默认名称。
*
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
*/
export function defaultCharacterAnimationResourceName(
resource: ProjectResource,
) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-角色动画`;
}
export function resourceEditPromptMaxLength(
editKind: LocalProjectResourceEditKind,
) {
@@ -9,6 +9,10 @@ const canvasFixture = vi.hoisted(() => ({
revision: 0,
}));
import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_MODEL,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
@@ -126,7 +130,13 @@ function LiveWorkbench() {
);
}
function DerivedWorkbench({ includeArt = false }: { includeArt?: boolean }) {
function DerivedWorkbench({
includeArt = false,
includeCharacter = false,
}: {
includeArt?: boolean;
includeCharacter?: boolean;
}) {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
@@ -150,6 +160,18 @@ function DerivedWorkbench({ includeArt = false }: { includeArt?: boolean }) {
},
]
: []),
...(includeCharacter
? [
{
id: 'source-character',
kind: 'character',
category: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', resourceId: 'hero-resource' },
},
]
: []),
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
@@ -530,6 +552,51 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
});
it('从角色资源卡生成动画:走 character-animation 派生,且不渲染会被后端忽略的参数入口', async () => {
const { deriveCalls } = installTauri();
render(<DerivedWorkbench includeCharacter />);
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '生成动画' }));
const panel = await screen.findByRole('dialog', {
name: '角色动画生成面板',
});
// 参数入口必须不存在:Rust 固定 720p / same / 32 帧 / 4 秒,改它不会改变请求。
expect(
within(panel).queryByRole('button', { name: /动画参数/ }),
).toBeNull();
await setComposerText(
within(panel).getByLabelText('动画描述'),
'角色挥手打招呼',
);
// 泥点价必须按 Rust 实际固定的 720p×4 秒算,而不是共享 draft 工厂的 480p 默认档:
// 档位与 Rust 常量不一致,按钮上就是一个与实际计费不符的数字(内置价表 20 vs 10 每秒)。
const submitButton = within(panel).getByRole('button', {
name: /生成[\d.]+泥点/,
});
expect(submitButton.textContent).toContain(
`${calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 4)}泥点`,
);
expect(submitButton.textContent).not.toContain(
`${calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '480p', 4)}泥点`,
);
fireEvent.click(submitButton);
await waitFor(() => expect(deriveCalls).toHaveLength(1));
expect(deriveCalls[0]?.editKind).toBe('character-animation');
expect(deriveCalls[0]?.generationMode).toBe('derive');
expect(deriveCalls[0]?.prompt).toBe('角色挥手打招呼');
expect(deriveCalls[0]?.assetName).toBe('hero-角色动画');
expect(deriveCalls[0]?.sourceAssetId).toBe('source-character');
expect(deriveCalls[0]?.sourceMediaType).toBe('image/png');
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
});
it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => {
const { deriveCalls, polishCalls } = installTauri({
failFirstDerive: true,
@@ -78,8 +78,10 @@ function toolbarActions(
* `docs/project-memory/shared-memory/pitfalls.md` 的「资源画布工具条的『改造』
* 『角色动画』按钮点了没反应」)。
*
* 当前真实接通的是「快速编辑」(normalize → derive(editKind='image-reference')
* 与「下载」(资源面板同一条原生保存对话框 + `save_local_project_asset_file` 链路)。
* 当前真实接通的是「快速编辑」(normalize → derive(editKind='image-reference')
* 「生成动画」(normalize → derive(editKind='character-animation') → 服务端
* `/api/editor/character-animations/generations`)与「下载」(资源面板同一条原生保存
* 对话框 + `save_local_project_asset_file` 链路)。
*/
describe('resource canvas toolbar actions', () => {
it('已登记 manifest 资产的栅格图片放行快速编辑与下载', () => {
@@ -120,13 +122,60 @@ describe('resource canvas toolbar actions', () => {
).toEqual(['download']);
});
it('角色资源放行快速编辑与下载,不再放行空回调的角色动画动作', () => {
it('角色资源放行「生成动画」:源是已登记的栅格图片', () => {
// 这条断言由「不再放行空回调的角色动画动作」改写而来(四不写:旧断言描述的是
// 未接通状态,已被真实链路取代,不留墓碑)。宿主编排层现在把
// `onOpenCharacterAnimationPanel` 接到 `submitResourceCharacterAnimation`
// derive editKind='character-animation'),所以进集合不再是假按钮。
expect(
toolbarActions(
createManifest(),
createResource({ id: 'asset:hero-action', subtype: 'character' }),
),
).toEqual(['quick-edit', 'character-animation', 'download']);
});
it('非角色的图片资源不放行生成动画,避免渲染出与源类型不匹配的按钮', () => {
expect(
toolbarActions(
createManifest(),
createResource({ id: 'asset:scene', subtype: 'scene' }),
),
).toEqual(['quick-edit', 'download']);
expect(
toolbarActions(
createManifest(),
createResource({ id: 'asset:icon', subtype: 'icon' }),
),
).toEqual(['quick-edit', 'download']);
});
it('角色资源没登记成正式素材时不放行生成动画,只留下真能跑通的下载', () => {
expect(
toolbarActions(
createManifest(),
createResource({
id: 'asset:hero-artifact',
subtype: 'character',
manifestAssetId: null,
producerTaskId: 'task-hero',
}),
),
).toEqual(['download']);
});
it('非栅格的角色资源不放行生成动画:Rust 要解码源图取宽高,SVG 会直接失败', () => {
expect(
toolbarActions(
createManifest(),
createResource({
id: 'asset:hero-svg',
subtype: 'character',
path: 'assets/hero.svg',
mediaType: 'image/svg+xml',
}),
),
).toEqual(['download']);
});
it('未登记 manifest 资产且没有已完成任务产物背书的资源只放行下载', () => {