diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx new file mode 100644 index 000000000..5932779f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -0,0 +1,190 @@ +import { Sparkles, X } from 'lucide-react'; +import { type FormEvent, useRef, useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; +import { + createResourceCanvasGenerationRequest, + RESOURCE_CANVAS_GENERATION_DEFAULT_KIND, + RESOURCE_CANVAS_GENERATION_OPTIONS, + type ResourceCanvasGenerationKind, + resourceCanvasGenerationOption, +} from './resourceCanvasGenerationModel'; + +export type ResourceCanvasGenerationSubmitInput = { + kind: ResourceCanvasGenerationKind; + operationId: string; + idempotencyKey: string; + prompt: string; + assetName: string; +}; + +export type ResourceCanvasGenerationPanelViewProps = { + initialKind?: ResourceCanvasGenerationKind; + onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; + onClose: () => void; +}; + +const RESOURCE_GENERATION_KIND_ITEMS = RESOURCE_CANVAS_GENERATION_OPTIONS.map( + (option) => ({ id: option.kind, label: option.label }), +); + +function resourceGenerationErrorMessage(error: unknown) { + if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error && error.message) return error.message; + return '生成素材失败'; +} + +/** + * 资源画布「生成入口」的浮层面板。 + * + * 形态是独立弹层(`ThemedModal`,与资源面板 / 分类面板同一套宿主 chrome), + * 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责, + * 面板只持有草稿、类型选择与失败重试状态。 + */ +export function ResourceCanvasGenerationPanelView({ + initialKind = RESOURCE_CANVAS_GENERATION_DEFAULT_KIND, + onSubmit, + onClose, +}: ResourceCanvasGenerationPanelViewProps) { + const [kind, setKind] = useState(initialKind); + const option = resourceCanvasGenerationOption(kind); + const [prompt, setPrompt] = useState(''); + const [assetName, setAssetName] = useState(option.assetName); + const [attempted, setAttempted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + // 同一次生成会话只申请一份请求身份:失败重试必须命中同一 operation 账本。 + const requestRef = useRef<{ + operationId: string; + idempotencyKey: string; + } | null>(null); + const inputLocked = attempted || submitting; + + async function submit(event: FormEvent) { + event.preventDefault(); + const normalizedPrompt = prompt.trim(); + const normalizedAssetName = assetName.trim(); + if (!normalizedPrompt || !normalizedAssetName || submitting) { + return; + } + setAttempted(true); + setSubmitting(true); + setError(null); + requestRef.current ??= createResourceCanvasGenerationRequest(); + try { + await onSubmit({ + kind, + operationId: requestRef.current.operationId, + idempotencyKey: requestRef.current.idempotencyKey, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + }); + } catch (submitError) { + setError(resourceGenerationErrorMessage(submitError)); + setSubmitting(false); + } + } + + return ( + { + if (!submitting) { + onClose(); + } + }} + panelClassName="game-approval-dialog game-resource-generation-dialog" + > +
+
+

生成素材

+
+ +
+ { + setKind(nextKind); + setAssetName(resourceCanvasGenerationOption(nextKind).assetName); + }} + /> +
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ + 取消 + + {error ? ( + + + ) : ( + + + )} +
+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index fe982d246..2b63f5113 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -392,4 +392,37 @@ .game-resource-panel-meta small { color: #8c6252; font-size: 0.68rem; +} + +/* 生成素材浮层:独立弹层承载类型选择、草稿与失败重试。 */ +.game-resource-generation-dialog { + width: min(560px, 100%); + max-height: min(720px, calc(100dvh - 40px)); + overflow: auto; +} + +.game-resource-generation-form { + display: grid; + gap: 12px; +} + +.game-resource-generation-form label { + display: grid; + gap: 6px; + color: #76594e; + font-size: 11px; + font-weight: 700; +} + +.game-resource-generation-error { + margin: 0; + color: #b3261e; + font-size: 11px; +} + +.game-resource-generation-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; } \ No newline at end of file diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts new file mode 100644 index 000000000..94463f4d7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationModel.ts @@ -0,0 +1,125 @@ +import type { LocalProjectResourceEditKind } from '../../view/project-development/resourceEditModel'; + +/** + * 资源画布「生成入口」当前能真正落地的三类新建素材。 + * + * 这里的白名单与 Rust `resolve_resource_edit_source` 的 create 分支一一对应: + * `apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs` 只放行 + * video / sound-effect / background-music,其余类型(含图片)会直接返回 + * “当前资源类型不支持无源生成”。因此入口只呈现这三类——宁可少一个入口, + * 也不给一个提交必然失败、或者拿图片参数糊弄用户的按钮。 + */ +export type ResourceCanvasGenerationKind = + | 'video' + | 'sound-effect' + | 'background-music'; + +export type ResourceCanvasGenerationOption = { + kind: ResourceCanvasGenerationKind; + editKind: LocalProjectResourceEditKind; + /** 浮层内类型选择器的短标签。 */ + label: string; + /** 入口按钮与提交按钮的完整文案。 */ + generationLabel: string; + sourceMediaType: string; + /** 新建素材的默认名称,与旧 `resourceGenerationOptions` 口径一致。 */ + assetName: string; + promptPlaceholder: string; +}; + +const VIDEO_GENERATION_OPTION: ResourceCanvasGenerationOption = { + kind: 'video', + editKind: 'video', + label: '视频', + generationLabel: '生成视频', + sourceMediaType: 'video/mp4', + assetName: '新视频', + promptPlaceholder: '描述想生成的视频画面、镜头与节奏', +}; + +const SOUND_EFFECT_GENERATION_OPTION: ResourceCanvasGenerationOption = { + kind: 'sound-effect', + editKind: 'sound-effect', + label: '音效', + generationLabel: '生成音效', + sourceMediaType: 'audio/mpeg', + assetName: '新音效', + promptPlaceholder: '描述想生成的音效,例如:木门缓慢推开时的吱呀声', +}; + +const BACKGROUND_MUSIC_GENERATION_OPTION: ResourceCanvasGenerationOption = { + kind: 'background-music', + editKind: 'background-music', + label: '背景音乐', + generationLabel: '生成背景音乐', + sourceMediaType: 'audio/mpeg', + assetName: '新背景音乐', + promptPlaceholder: '描述想生成的背景音乐风格、情绪与乐器', +}; + +export const RESOURCE_CANVAS_GENERATION_OPTIONS: readonly ResourceCanvasGenerationOption[] = + [ + VIDEO_GENERATION_OPTION, + SOUND_EFFECT_GENERATION_OPTION, + BACKGROUND_MUSIC_GENERATION_OPTION, + ]; + +export const RESOURCE_CANVAS_GENERATION_DEFAULT_KIND: ResourceCanvasGenerationKind = + 'video'; + +export function resourceCanvasGenerationOption( + kind: ResourceCanvasGenerationKind, +): ResourceCanvasGenerationOption { + if (kind === 'sound-effect') { + return SOUND_EFFECT_GENERATION_OPTION; + } + if (kind === 'background-music') { + return BACKGROUND_MUSIC_GENERATION_OPTION; + } + return VIDEO_GENERATION_OPTION; +} + +/** + * create 模式的稳定源标识。 + * + * 无源生成没有真实源资源,Rust 只把这个字符串当作 canonical resource id 记账, + * 不再回读源文件;用 operationId 拼出来保证同一 operation 的流水可对账。 + */ +export function resourceCanvasGenerationSourceId(operationId: string) { + return `create:${operationId.trim()}`; +} + +/** + * 入口按钮的渲染门禁。 + * + * 没有客户端 invoke 桥(浏览器里打开的构建)或项目还没就绪时,生成能力不成立, + * 入口整体不渲染,避免留下点了没反应的按钮。 + */ +export function isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke, + projectPath, + projectId, +}: { + hasRuntimeInvoke: boolean; + projectPath: string; + projectId: string; +}) { + return ( + hasRuntimeInvoke && + projectPath.trim().length > 0 && + projectId.trim().length > 0 + ); +} + +/** + * 一次生成会话的请求身份。 + * + * 面板打开时生成一次,失败重试继续复用同一对 operationId / idempotencyKey: + * 命中同一 operation 账本,既不会重复扣费,也不会在服务端另起一次生成。 + */ +export function createResourceCanvasGenerationRequest() { + return { + operationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + }; +} 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 92b472c9a..6ccbb4fa4 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 @@ -95,6 +95,15 @@ import { resolveResourceCanvasPanelEntries, resolveSelectedPanelEntries, } from '../../features/resource-canvas/resourceCanvasAssetTransferModel'; +import { + isResourceCanvasGenerationAvailable, + resourceCanvasGenerationOption, + resourceCanvasGenerationSourceId, +} from '../../features/resource-canvas/resourceCanvasGenerationModel'; +import { + ResourceCanvasGenerationPanelView, + type ResourceCanvasGenerationSubmitInput, +} from '../../features/resource-canvas/ResourceCanvasGenerationPanelView'; import { canRedoResourceCanvasHistory, canUndoResourceCanvasHistory, @@ -1247,6 +1256,7 @@ export default function ProjectDevelopmentView({ createResourceCanvasHistory, ); const [resourcePanelOpen, setResourcePanelOpen] = useState(false); + const [resourceGenerationOpen, setResourceGenerationOpen] = useState(false); const [resourcePanelNotice, setResourcePanelNotice] = useState(''); const [resourcePanelUploading, setResourcePanelUploading] = useState(false); const [uiEditorRoute, setUiEditorRoute] = useState( @@ -5065,6 +5075,86 @@ export default function ProjectDevelopmentView({ quickEditSourceLayer, ]); + /** + * 生成入口:从资源画布无源新建视频 / 音效 / 背景音乐。 + * + * 产出物是一张全新的资源卡(`generationMode: 'create'`),源素材、画布布局与 + * 已有 manifest 条目都不参与派生;成功后用 `pendingResourceFocusRef` 定位新卡。 + */ + const submitResourceCanvasGeneration = useCallback( + async (input: ResourceCanvasGenerationSubmitInput) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('生成资源需要在客户端内执行'); + } + const option = resourceCanvasGenerationOption(input.kind); + const actionProject = { projectPath, projectId: manifest.projectId }; + const flowId = crypto.randomUUID(); + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: actionProject.projectId, + expectedProjectRevision: status.revision, + operationId: input.operationId, + idempotencyKey: input.idempotencyKey, + editKind: option.editKind, + generationMode: 'create', + sourceResourceId: resourceCanvasGenerationSourceId( + input.operationId, + ), + sourceAssetId: null, + sourcePath: null, + sourceMediaType: option.sourceMediaType, + sourceSubtype: null, + producerTaskId: null, + sourceVersionId: null, + prompt: input.prompt, + assetName: input.assetName, + }, + }, + ), + ); + 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, + }; + setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); + setResourceGenerationOpen(false); + }, + [manifest.projectId, onManifestChange, projectPath], + ); + const selectedResourceCardPreview = selectedResourcePreviewIdentity ? (resourceCardPreviews.previews.get(selectedResourcePreviewIdentity) ?? null) @@ -5127,6 +5217,12 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + // 没有客户端 invoke 桥或项目还没就绪时不渲染生成入口,避免留下点了没反应的按钮。 + const resourceGenerationAvailable = isResourceCanvasGenerationAvailable({ + hasRuntimeInvoke: Boolean(window.__TAURI__?.core?.invoke), + projectPath, + projectId: manifest.projectId, + }); return (