diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts index c8d63a803..f5a058fd9 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts @@ -109,10 +109,14 @@ export function resolveResourceCanvasToolbarActions( resource: ProjectResource, ): ReadonlySet { // 判据只有一条:**放进来就必须真能跑通**。工具条按这个集合渲染按钮,未接通的动作 - // 一律不渲染,避免出现"点了没反应"的假按钮。当前真实接通的只有「快速编辑」 - // (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(); 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'); + } } } // 「下载」的判据不是媒体类型,而是"拿得到可落盘的本地文件路径":虚拟版本条目没有 diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 767290844..c45aa1a30 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -5,6 +5,7 @@ import type { import { createCanvasMarqueeState, getPointerClient, + resolveCharacterAnimationPanelStyle, resolveQuickEditPanelStyle, resolveSelectedToolbarStyle, selectLayersInsideMarquee, @@ -70,10 +71,18 @@ import type { GameIterationVersion, ProjectResourceCanvasLayoutMode, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView'; import type { CanvasLayer, + CharacterAnimationPanelState, QuickEditPanelState, } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { createCharacterAnimationPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel'; +import { + calculateCharacterAnimationPrice, + CHARACTER_ANIMATION_DURATION_OPTIONS, + CHARACTER_ANIMATION_MODEL, +} from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; @@ -216,7 +225,9 @@ import { import { canonicalProjectedResourceMediaType, createResourceEditRequestIdentity, + defaultCharacterAnimationResourceName, defaultDerivedResourceName, + resolveProjectCharacterAnimationCapability, resolveResourceEditRequestIdentity, type ResourceEditRequestIdentity, } from './resourceEditModel'; @@ -316,6 +327,31 @@ type PendingLocalProjectResourceEdit = { createdAt: number; }; +/** + * 一次资源派生的请求身份,外加源解析过程中要缓存的两条事实。 + * + * `normalizedAssetId` 与 `expectedProjectRevision` 是重试时要复用的事实:同一个源被反复 + * 正规化会重复写 manifest,revision 取错又会被后端判旧。两者都由 + * `resolveResourceDeriveSource` 回写。 + */ +type ResourceDeriveRequest = ResourceEditRequestIdentity & { + sourceLayerId: string; + normalizedAssetId: string | null; + expectedProjectRevision: number | null; +}; + +/** + * 「生成动画」在 AGC 侧固定的参数档位。 + * + * 必须与 `apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs` 的 + * CharacterAnimation 分支逐字一致(`resolution: "720p"` / `frameCount: 32` / + * `durationSeconds: 4` / `model: seedance2.0-fast` / `ratio: "same"`):面板上的泥点价按 + * 这些值算,Rust 的请求也按这些值发;两边不一致,按钮上就会出现与实际计费不符的数字。 + * 参数入口因此不渲染——改它不会改变请求。 + */ +const RESOURCE_CHARACTER_ANIMATION_RESOLUTION = '720p' as const; +const RESOURCE_CHARACTER_ANIMATION_DURATION_SECONDS = 4; + type PendingResourceEditsLoadState = 'idle' | 'loading' | 'ready' | 'failed'; type ResourceEditServiceIdentityConfirmation = { @@ -1378,6 +1414,16 @@ export default function ProjectDevelopmentView({ useState(null); const [quickEditPanel, setQuickEditPanel] = useState(null); + /** + * 「生成动画」的源与浮层。 + * + * 与快速编辑分开持有:两者都是挂在资源卡旁的浮层,但同时只能开一个,且各自绑定自己 + * 的源资源——共用一份状态会让「A 卡的动画面板」显示成「B 卡的源」。 + */ + const [characterAnimationSourceLayer, setCharacterAnimationSourceLayer] = + useState(null); + const [characterAnimationPanel, setCharacterAnimationPanel] = + useState(null); /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); const [resourceCanvasMarquee, setResourceCanvasMarquee] = @@ -1504,17 +1550,19 @@ export default function ProjectDevelopmentView({ * 否则一次用户动作会在后端留下两条派生记录;提示词变了(改写或润色回填)则重铸, * 见 `resolveResourceEditRequestIdentity`。 */ - const resourceQuickEditRequestRef = useRef< - | (ResourceEditRequestIdentity & { - sourceLayerId: string; - normalizedAssetId: string | null; - expectedProjectRevision: number | null; - }) - | null - >(null); + const resourceQuickEditRequestRef = useRef( + null, + ); + /** 「生成动画」的请求身份,与快速编辑同一套复用/重铸口径。 */ + const resourceCharacterAnimationRequestRef = + useRef(null); /** 快速编辑面板的最新状态:清焦点回调只在事件里读,不随面板状态反复重建。 */ const quickEditPanelRef = useRef(null); quickEditPanelRef.current = quickEditPanel; + /** 「生成动画」面板的最新状态,读法同上。 */ + const characterAnimationPanelRef = + useRef(null); + characterAnimationPanelRef.current = characterAnimationPanel; const closeResourceQuickEditPanel = useCallback(() => { setQuickEditPanel(null); @@ -1522,6 +1570,12 @@ export default function ProjectDevelopmentView({ resourceQuickEditRequestRef.current = null; }, []); + const closeResourceCharacterAnimationPanel = useCallback(() => { + setCharacterAnimationPanel(null); + setCharacterAnimationSourceLayer(null); + resourceCharacterAnimationRequestRef.current = null; + }, []); + /** * 清资源画布焦点:对齐美术画布 `clearCanvasFocus()`。 * @@ -1538,7 +1592,13 @@ export default function ProjectDevelopmentView({ return; } closeResourceQuickEditPanel(); - }, [closeResourceQuickEditPanel]); + // 「生成动画」与快速编辑同样是挂在资源卡旁的浮层,走同一套「点外部 / Esc 关闭」 + // 时机与「生成中不关」判据,否则按下 Esc 会只收起一个浮层。 + if (characterAnimationPanelRef.current?.status === 'generating') { + return; + } + closeResourceCharacterAnimationPanel(); + }, [closeResourceCharacterAnimationPanel, closeResourceQuickEditPanel]); /** * 画布浮层的「点外部 / Esc 关闭」。 @@ -3962,10 +4022,13 @@ export default function ProjectDevelopmentView({ advanceFocusGeneration(); stopActiveCardMedia(); setHiddenCommittedResourceId(null); - // 换选中即关掉快速编辑浮层,避免面板挂在上一个卡片上。 + // 换选中即关掉快速编辑与生成动画浮层,避免面板挂在上一个卡片上。 setQuickEditPanel(null); setQuickEditSourceLayer(null); resourceQuickEditRequestRef.current = null; + setCharacterAnimationPanel(null); + setCharacterAnimationSourceLayer(null); + resourceCharacterAnimationRequestRef.current = null; setSelectedResourceIds((current) => { if (!options.append) { return [resourceId]; @@ -5199,6 +5262,86 @@ export default function ProjectDevelopmentView({ null) : null; + /** + * 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。 + * + * 「快速编辑」与「生成动画」共用这一段——两者的差别只在 editKind、素材名与提示词上限, + * 源解析、正规化写回与 revision 缓存的口径必须逐字一致,否则同一个源会在两条链路上 + * 得到两份不同的 manifest 视图。取到的两条事实(`normalizedAssetId` / + * `expectedProjectRevision`)回写到 `request` 上,重试时不再重复正规化。 + * + * `actionLabel` 只进错误文案:用户看到的必须是"哪个动作做不了",而不是笼统的资源错误。 + */ + const resolveResourceDeriveSource = useCallback( + async ({ + resource, + request, + actionLabel, + }: { + resource: ProjectResource; + request: ResourceDeriveRequest; + actionLabel: string; + }) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error(`${actionLabel}需要在客户端内执行`); + } + const actionProject = { projectPath, projectId: manifest.projectId }; + let expectedProjectRevision = request.expectedProjectRevision; + if (expectedProjectRevision === null) { + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + expectedProjectRevision = status.revision; + } + let sourceResourceId = resource.id; + let sourceAssetId = request.normalizedAssetId ?? resource.manifestAssetId; + if (!sourceAssetId) { + if (!canNormalizeResourceIntoManifestAsset(manifest, resource)) { + throw new Error(`该资源尚未登记为正式素材,无法${actionLabel}`); + } + const normalized = await withPlatformSessionRefresh(() => + invoke<{ + committedProjectRevision: number; + asset: GameCreationAppAssetManifestEntry; + manifest: GameCreationAppManifest; + }>('normalize_local_project_raster_resource', { + input: { + projectPath, + expectedProjectId: actionProject.projectId, + expectedProjectRevision, + sourceResourceId: resource.id, + sourcePath: resource.path, + sourceMediaType: canonicalProjectedResourceMediaType(resource), + sourceSubtype: resource.subtype, + producerTaskId: resource.producerTaskId ?? '', + }, + }), + ); + if (normalized.manifest.projectId !== actionProject.projectId) { + throw new Error('正规化结果与当前项目不一致'); + } + onManifestChange?.(projectPath, normalized.manifest, { + projectId: normalized.manifest.projectId, + revision: normalized.committedProjectRevision, + source: 'asset-command', + commitId: `normalize:${normalized.asset.id}`, + }); + sourceAssetId = normalized.asset.id; + sourceResourceId = `asset:${normalized.asset.id}`; + expectedProjectRevision = normalized.committedProjectRevision; + request.normalizedAssetId = normalized.asset.id; + } + request.expectedProjectRevision = expectedProjectRevision; + return { sourceResourceId, sourceAssetId, expectedProjectRevision }; + }, + [manifest, onManifestChange, projectPath], + ); + const openResourceQuickEditPanel = useCallback( (layer: CanvasLayer) => { const resource = canvasResources.find((item) => item.id === layer.id); @@ -5293,56 +5436,12 @@ export default function ProjectDevelopmentView({ : current, ); try { - let expectedProjectRevision = request.expectedProjectRevision; - if (expectedProjectRevision === null) { - const status = await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ); - if (!Number.isSafeInteger(status.revision) || status.revision < 0) { - throw new Error('项目 revision 无效'); - } - expectedProjectRevision = status.revision; - } - let sourceResourceId = resource.id; - let sourceAssetId = request.normalizedAssetId ?? resource.manifestAssetId; - if (!sourceAssetId) { - if (!canNormalizeResourceIntoManifestAsset(manifest, resource)) { - throw new Error('该资源尚未登记为正式素材,无法快速编辑'); - } - const normalized = await withPlatformSessionRefresh(() => - invoke<{ - committedProjectRevision: number; - asset: GameCreationAppAssetManifestEntry; - manifest: GameCreationAppManifest; - }>('normalize_local_project_raster_resource', { - input: { - projectPath, - expectedProjectId: actionProject.projectId, - expectedProjectRevision, - sourceResourceId: resource.id, - sourcePath: resource.path, - sourceMediaType: canonicalProjectedResourceMediaType(resource), - sourceSubtype: resource.subtype, - producerTaskId: resource.producerTaskId ?? '', - }, - }), - ); - if (normalized.manifest.projectId !== actionProject.projectId) { - throw new Error('正规化结果与当前项目不一致'); - } - onManifestChange?.(projectPath, normalized.manifest, { - projectId: normalized.manifest.projectId, - revision: normalized.committedProjectRevision, - source: 'asset-command', - commitId: `normalize:${normalized.asset.id}`, + const { sourceResourceId, sourceAssetId, expectedProjectRevision } = + await resolveResourceDeriveSource({ + resource, + request, + actionLabel: '快速编辑', }); - sourceAssetId = normalized.asset.id; - sourceResourceId = `asset:${normalized.asset.id}`; - expectedProjectRevision = normalized.committedProjectRevision; - request.normalizedAssetId = normalized.asset.id; - } - request.expectedProjectRevision = expectedProjectRevision; const result = await withPlatformSessionRefresh(() => invoke( 'derive_local_project_resource', @@ -5416,6 +5515,210 @@ export default function ProjectDevelopmentView({ projectPath, quickEditPanel, quickEditSourceLayer, + resolveResourceDeriveSource, + ]); + + /** + * 「生成动画」:把选中的角色图派生成一段动作序列帧,源素材不动。 + * + * 直接复用美术画布那一块动画面板(`ImageCanvasCharacterAnimationPanelView`):同一份源图 + * 缩略、同一套动作预设、同一个 4000 字上限,这正是 §C3「交互 1:1 复刻」要的形态。 + * **参数入口显式关掉**:比例 / 时长 / 清晰度由客户端 Rust 侧固定(`same` / 720p / 32 帧 / + * 4 秒 / `seedance2.0-fast`),面板改它们不会改变请求,渲染出来就是点了不生效的假控件。 + * + * 面板上的泥点价走 `calculateCharacterAnimationPrice`,与美术画布同一个算法、同一份价格 + * 配置;档位常量必须与 Rust 里固定的那三个值一致,否则按钮上会显示与实际计费不符的数字。 + */ + const openResourceCharacterAnimationPanel = useCallback( + (layer: CanvasLayer) => { + const resource = canvasResources.find((item) => item.id === layer.id); + if ( + !resource || + resolveProjectCharacterAnimationCapability(resource) === null + ) { + return; + } + const panelDraft = createCharacterAnimationPanelDraft(layer); + if (!panelDraft) { + return; + } + closeResourceQuickEditPanel(); + setCharacterAnimationSourceLayer(layer); + setCharacterAnimationPanel({ + ...panelDraft, + resolution: RESOURCE_CHARACTER_ANIMATION_RESOLUTION, + }); + resourceCharacterAnimationRequestRef.current = { + ...createResourceEditRequestIdentity(''), + sourceLayerId: layer.id, + normalizedAssetId: null, + expectedProjectRevision: null, + }; + setResourceWorkbenchNotice(''); + }, + [canvasResources, closeResourceQuickEditPanel], + ); + + /** + * 参数档位变化。 + * + * AGC 侧参数入口是关掉的,所以这条通路常态不可达;仍然按面板自身的契约真实更新状态 + * (而不是留一个空回调),将来若放行参数入口,值至少是自洽的。 + */ + const updateResourceCharacterAnimationDuration = useCallback( + (frameCountValue: string) => { + const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find( + (candidate) => String(candidate.frameCount) === frameCountValue, + ); + if (!option) { + return; + } + setCharacterAnimationPanel((current) => + current + ? { + ...current, + frameCount: option.frameCount, + durationSeconds: option.durationSeconds, + status: current.status === 'failed' ? 'idle' : current.status, + errorMessage: + current.status === 'failed' ? undefined : current.errorMessage, + } + : current, + ); + }, + [], + ); + + const submitResourceCharacterAnimation = useCallback(async () => { + const panel = characterAnimationPanel; + const layer = characterAnimationSourceLayer; + const resource = layer + ? canvasResources.find((item) => item.id === layer.id) + : undefined; + const capability = resource + ? resolveProjectCharacterAnimationCapability(resource) + : null; + const invoke = window.__TAURI__?.core?.invoke; + const prompt = panel?.promptText.trim() ?? ''; + if (!panel || !layer || !resource || !capability || !invoke || !prompt) { + if (panel && !invoke) { + setCharacterAnimationPanel((current) => + current + ? { + ...current, + status: 'failed', + errorMessage: '生成动画需要在客户端内打开', + } + : current, + ); + } + return; + } + const actionProject = { projectPath, projectId: manifest.projectId }; + const flowId = crypto.randomUUID(); + const sourceLayerId = layer.id; + const previousRequest = resourceCharacterAnimationRequestRef.current; + // 与快速编辑同一套口径:提示词没变就复用 operationId / 幂等键,失败重试命中同一 + // operation 账本;改了提示词必须重铸,否则 Rust 的 request_fingerprint 会判不一致。 + const request = + previousRequest?.sourceLayerId === sourceLayerId + ? { + ...previousRequest, + ...resolveResourceEditRequestIdentity(previousRequest, prompt), + } + : { + ...createResourceEditRequestIdentity(prompt), + sourceLayerId, + normalizedAssetId: null, + expectedProjectRevision: null, + }; + resourceCharacterAnimationRequestRef.current = request; + setCharacterAnimationPanel((current) => + current + ? { ...current, status: 'generating', errorMessage: undefined } + : current, + ); + try { + const { sourceResourceId, sourceAssetId, expectedProjectRevision } = + await resolveResourceDeriveSource({ + resource, + request, + actionLabel: '生成动画', + }); + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: actionProject.projectId, + expectedProjectRevision, + operationId: request.operationId, + idempotencyKey: request.idempotencyKey, + editKind: capability.editKind, + generationMode: 'derive', + sourceResourceId, + sourceAssetId, + sourcePath: null, + sourceMediaType: capability.sourceMediaType, + sourceSubtype: null, + producerTaskId: null, + sourceVersionId: null, + prompt, + assetName: defaultCharacterAnimationResourceName(resource), + }, + }, + ), + ); + if ( + !result.asset || + result.manifest.projectId !== actionProject.projectId + ) { + throw new Error('生成动画结果与当前项目不一致'); + } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + commitId: result.operationId, + }); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: result.operationId, + sessionId: result.operationId, + draftId: result.operationId, + commitId: result.operationId, + projectPath, + projectId: result.manifest.projectId, + focusGeneration: focusGenerationRef.current, + resourceId: `asset:${result.asset.id}`, + completed: false, + }; + setCharacterAnimationPanel(null); + setCharacterAnimationSourceLayer(null); + resourceCharacterAnimationRequestRef.current = null; + setResourceWorkbenchNotice('生成动画已产出新素材,正在同步资源与布局…'); + } catch (error) { + setCharacterAnimationPanel((current) => + current + ? { + ...current, + status: 'failed', + errorMessage: + error instanceof Error ? error.message : String(error), + } + : current, + ); + } + }, [ + canvasResources, + characterAnimationPanel, + characterAnimationSourceLayer, + manifest, + onManifestChange, + projectPath, + resolveResourceDeriveSource, ]); /** @@ -5546,6 +5849,22 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + // 「生成动画」用共享动画面板自己的锚点算法(与快速编辑不是同一套),同样直接复用。 + const characterAnimationPanelStyle = + characterAnimationPanel && characterAnimationSourceLayer + ? resolveCharacterAnimationPanelStyle({ + panel: characterAnimationPanel, + sourceLayer: characterAnimationSourceLayer, + viewport: resourceCanvasSceneViewportRef.current, + canvasSize: resourceBookSceneSize, + }) + : null; + // 与美术画布同一个价格算法、同一份价格配置;档位取 Rust 固定的那三个值。 + const characterAnimationPrice = calculateCharacterAnimationPrice( + CHARACTER_ANIMATION_MODEL, + RESOURCE_CHARACTER_ANIMATION_RESOLUTION, + RESOURCE_CHARACTER_ANIMATION_DURATION_SECONDS, + ); // 没有客户端 invoke 桥或项目还没就绪时不渲染生成入口,避免留下点了没反应的按钮。 const resourceGenerationAvailable = isResourceCanvasGenerationAvailable({ hasRuntimeInvoke: Boolean(window.__TAURI__?.core?.invoke), @@ -5892,7 +6211,9 @@ export default function ProjectDevelopmentView({ isPersistingAssetKind={false} onSplitIconSpritesheet={() => {}} onExtractUiDesignAssets={() => {}} - onOpenCharacterAnimationPanel={() => {}} + onOpenCharacterAnimationPanel={ + openResourceCharacterAnimationPanel + } onDownloadLayer={() => { if ( !selectedResource || @@ -5937,6 +6258,27 @@ export default function ProjectDevelopmentView({ ); })() : null} + {characterAnimationPanel && + characterAnimationPanelStyle && + characterAnimationSourceLayer ? ( + // 与美术画布同一块动画面板;参数入口关掉,因为 Rust 固定了那三个档位。 + { + void submitResourceCharacterAnimation(); + }} + /> + ) : null} {resourceInfoPanelOpen && selectedResource && selectedResourceLayer ? ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts index 70d7ba916..27a1aec33 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts @@ -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, ) { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index cad3f0d05..cc4ac67bc 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -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(); + + 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, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index 56350d935..3e83c916d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -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 资产且没有已完成任务产物背书的资源只放行下载', () => {