From 097d86d337b8142c7dd426f47f2519a036fd9003 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:42:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E7=BA=BF=20AGC=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=BA=A7=E8=B5=84=E6=BA=90=E6=9B=BF=E6=8D=A2=E7=9A=84=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=93=BE=E8=B7=AF=E4=B8=8E=E5=BC=B9=E7=AA=97=E5=A4=8D?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 resourceVersionReplacementTransport.ts:两个 IPC 边界(read_..._candidates / replace_..._resource)只传一个 input 对象,不在客户端内执行时明确报错;这两个 invoke 调用点是 check-config.mjs「每个 Tauri 命令都要有 App invoke 调用方」的落点 - 新增 resourceVersionReplacementModel.ts:PRD §5.3 的 ProjectVersionResourceReplacement / 兼容性类型、禁用原因与失败文案映射、候选→弹窗素材映射;判据不在前端重算,只做呈现 - 候选→弹窗素材复用资源投影口径(resourceCanvasMediaType / resourceCanvasAssetKind),src 固定为空串,缩略图交给弹窗的 renderAssetMedia,不给 img 喂空 src - ImageCanvasProjectAssetPickerDialog 以 6 个可选 prop 扩展:singleSelect / assetBlockedReasons / renderAssetMedia / selectionNoun / errorMessage 与新的禁用原因文案;全部默认值保持网页端美术画布行为逐字不变(默认仍是多选、仍是 img、仍是「参考图」文案、不渲染禁用与错误区) - project-development/index.tsx 接线:状态与处理函数(读候选→开弹窗→CAS 写入→重读 manifest→selectActiveVersion),失败保留弹窗并显示原因,候选读取失败不弹空壳弹窗而是说明原因;本提交暂不放行工具条入口 - styles.css 增 .game-resource-replacement-media:候选行的类型占位样式,避免挂破图 --- .../resourceVersionReplacementModel.ts | 161 ++++++++++++++++ .../resourceVersionReplacementTransport.ts | 49 +++++ apps/ai-game-creator-shell/src/styles.css | 15 ++ .../src/view/project-development/index.tsx | 173 ++++++++++++++++++ .../ImageCanvasProjectAssetPickerDialog.tsx | 89 +++++++-- 5 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts new file mode 100644 index 000000000..018a378e4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -0,0 +1,161 @@ +import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; +import { + resourceCanvasAssetKind, + resourceCanvasMediaType, +} from './resourceCanvasToolbarModel'; + +/** + * 「替换素材」的展示层口径:类型、禁用原因与失败文案。 + * + * 三项兼容性的**判据在 Rust**(`project/version_resource_replacement.rs`),前端只做呈现, + * 不在这里重算判据 —— 否则同一个规则会出现两份实现,迟早分叉。 + */ + +/** PRD §5.3 的三项兼容性;三项必须同时为 `true` 才能创建下一版本。 */ +export type ProjectVersionResourceCompatibility = { + categoryEqual: boolean; + subtypeEqual: boolean; + sizeSpecEqual: boolean; +}; + +export type LocalProjectVersionReplacementCandidate = { + resourceId: string; + compatible: boolean; + compatibility: ProjectVersionResourceCompatibility; + blockedReason: string | null; +}; + +export type ReadLocalProjectVersionReplacementCandidatesResult = { + sourceVersionId: string; + sourceResourceId: string; + candidates: LocalProjectVersionReplacementCandidate[]; +}; + +/** PRD §5.3 的 `ProjectVersionResourceReplacement`。 */ +export type ProjectVersionResourceReplacement = { + sourceVersionId: string; + sourceResourceId: string; + replacementResourceId: string; + compatibility: ProjectVersionResourceCompatibility; +}; + +export type ReplaceLocalProjectVersionResourceResult = { + versionId: string; + parentVersionId: string; + committedProjectRevision: number; + replacement: ProjectVersionResourceReplacement; +}; + +/** + * 三项兼容性对应的中文原因,按 PRD §5.3 的字段顺序取第一条不等的维度。 + * + * 后端已经给出 `blockedReason`;这里只在它缺失(旧后端 / 手工构造的候选)时按同一顺序派生, + * 不让界面出现"不可选但不说原因"的条目。 + */ +export function resourceReplacementBlockedReason( + candidate: LocalProjectVersionReplacementCandidate, +): string | null { + if (candidate.compatible) return null; + const reason = candidate.blockedReason?.trim(); + if (reason) return reason; + if (!candidate.compatibility.categoryEqual) return '分类不同'; + if (!candidate.compatibility.subtypeEqual) return '类型不同'; + if (!candidate.compatibility.sizeSpecEqual) return '尺寸规格不同'; + return '替换兼容性未通过'; +} + +/** + * 候选 → 禁用原因表,喂给弹窗的 `assetBlockedReasons`。 + * + * 不兼容候选**渲染但禁用**:隐藏会让用户以为"素材不存在",而真实原因是它不能替换这个素材。 + */ +export function resourceReplacementBlockedReasons( + candidates: readonly LocalProjectVersionReplacementCandidate[], +): Record { + const reasons: Record = {}; + for (const candidate of candidates) { + const reason = resourceReplacementBlockedReason(candidate); + if (reason) reasons[candidate.resourceId] = reason; + } + return reasons; +} + +/** + * 候选 → 弹窗素材。 + * + * `src` 固定为空字符串:AGC 的原生预览读取走带 scope 的调度器 + Blob URL,弹窗里拿不到同步 + * `src`,所以缩略图由调用方通过弹窗的 `renderAssetMedia` opt-in 渲染,**不给 `` 喂空串** + * (那会挂破图)。分类筛选复用语资源画布同一条媒体类型口径。 + */ +export function resourceReplacementPickerAssets( + resources: readonly ProjectResource[], + candidates: readonly LocalProjectVersionReplacementCandidate[], +): EditorAsset[] { + const resourceByAssetId = new Map(); + for (const resource of resources) { + const assetId = resource.manifestAssetId; + if (assetId && !resourceByAssetId.has(assetId)) { + resourceByAssetId.set(assetId, resource); + } + } + return candidates.flatMap((candidate) => { + const resource = resourceByAssetId.get(candidate.resourceId); + if (!resource) return []; + const assetKind = resourceCanvasAssetKind(resource); + return [ + { + id: candidate.resourceId, + label: resource.label, + src: '', + mediaType: resourceCanvasMediaType(resource) ?? 'image', + width: 0, + height: 0, + folderId: 'project-assets', + sourceKind: 'uploaded', + sourceType: 'generated', + persisted: true, + ...(assetKind ? { assetKind } : {}), + }, + ]; + }); +} + +/** 入口判据复用现役口径:`isResourceUsedByCurrentVersion`(manifest 身份 + 被当前版本绑定)。 */ + +/** + * 把 Rust 的结构化拒绝翻成用户可读中文。 + * + * 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。 + */ +export function resourceVersionReplacementErrorMessage(error: unknown): string { + const message = + error instanceof Error ? error.message : String(error ?? '').trim(); + if (!message) return '替换素材失败'; + if (message.includes('project-revision-conflict')) { + return '项目已被其它操作改动,请重试替换'; + } + if (message.includes('project-identity-conflict')) { + return '项目身份不一致,请重新打开项目后再试'; + } + if (message.includes('resource-replacement-incompatible')) { + const reason = message.split(':').pop()?.trim(); + return reason ? `替换素材不兼容:${reason}` : '替换素材不兼容'; + } + if (message.includes('源项目版本不存在')) { + return '当前版本已不存在,请刷新项目后重试'; + } + if (message.includes('源版本未绑定该素材')) { + return '该素材不在当前版本的绑定里,不能替换'; + } + if (message.includes('项目资源不存在')) { + return '替换素材未登记或已被删除'; + } + if (message.includes('替换素材与源素材相同')) { + return '替换素材与源素材相同'; + } + if (message.includes('需要在客户端内执行')) { + return '替换素材需要在客户端内执行'; + } + return message; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts new file mode 100644 index 000000000..c6f8abab3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementTransport.ts @@ -0,0 +1,49 @@ +import type { + ReadLocalProjectVersionReplacementCandidatesResult, + ReplaceLocalProjectVersionResourceResult, +} from './resourceVersionReplacementModel'; + +/** + * 版本级资源替换的 IPC 边界。 + * + * 两个命令都只吃单个 `input` 对象:Rust 侧入参是 `deny_unknown_fields` 的结构体, + * 多传字段会直接失败关闭。 + */ + +function replacementInvoke() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + // 与素材删除同口径:不在客户端内执行时明确报错,不做"看起来成功"的静默返回。 + throw new Error('替换素材需要在客户端内执行'); + } + return invoke; +} + +/** 读取源素材可用的替换候选与后端权威的三项兼容性结论(只读)。 */ +export async function readVersionResourceReplacementCandidates(input: { + projectPath: string; + sourceVersionId: string; + sourceResourceId: string; +}): Promise { + const invoke = replacementInvoke(); + return invoke( + 'read_local_project_version_resource_replacement_candidates', + { input }, + ); +} + +/** 改 manifest 绑定并追加下一迭代版本(不回滚:成功即已落盘)。 */ +export async function replaceVersionResource(input: { + projectPath: string; + expectedProjectId: string; + expectedProjectRevision: number; + sourceVersionId: string; + sourceResourceId: string; + replacementResourceId: string; +}): Promise { + const invoke = replacementInvoke(); + return invoke( + 'replace_local_project_version_resource', + { input }, + ); +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index eff4c5325..9af57283d 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -6341,6 +6341,21 @@ iframe.preview-frame { color: #9b5537; } +/* + * 「替换素材」候选弹窗里的类型占位。 + * + * AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`, + * 所以候选行只渲染稳定类型图标,不挂 ``、不出现破图。 + */ +.game-resource-replacement-media { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #8d7a6b; +} + .game-attachment-errors { display: grid; gap: 4px; 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 9644a0850..746b01ce5 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 @@ -41,6 +41,7 @@ import { Sparkles, Undo2, Users, + Video, X, ZoomOut, } from 'lucide-react'; @@ -74,6 +75,7 @@ import type { CanvasLayer, QuickEditPanelState, } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; +import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; @@ -84,6 +86,7 @@ import { } from '../../features/project-workspace/LocalGamePreviewFrame'; import { dispatchResourceReferenceInsert, + resolveActiveIterationVersion, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; @@ -132,6 +135,16 @@ import { currentVersionResourceBindingIds, isResourceUsedByCurrentVersion, } from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; +import { + resourceReplacementBlockedReasons, + resourceReplacementPickerAssets, + resourceVersionReplacementErrorMessage, + type LocalProjectVersionReplacementCandidate, +} from '../../features/resource-canvas/resourceVersionReplacementModel'; +import { + readVersionResourceReplacementCandidates, + replaceVersionResource, +} from '../../features/resource-canvas/resourceVersionReplacementTransport'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -1427,6 +1440,24 @@ export default function ProjectDevelopmentView({ const [resourceRenameError, setResourceRenameError] = useState( null, ); + /** + * 版本级资源替换(PRD §3.2 / §5.3):改 manifest 绑定,落盘为"新版本 + 新绑定"。 + * + * 源身份在打开弹窗时冻结(`sourceVersionId + sourceResourceId`),候选与兼容性结论全部来自 + * Rust;前端不重算判据,也不在失败时伪造成功。 + */ + const [resourceReplacementSource, setResourceReplacementSource] = useState<{ + versionId: string; + assetId: string; + } | null>(null); + const [resourceReplacementCandidates, setResourceReplacementCandidates] = + useState([]); + const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false); + const [resourceReplacementLoading, setResourceReplacementLoading] = + useState(false); + const [resourceReplacementError, setResourceReplacementError] = useState< + string | null + >(null); const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = useState>(() => new Set()); const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] = @@ -2578,6 +2609,121 @@ export default function ProjectDevelopmentView({ }, [reloadManifestAfterAssetCommand], ); + /** + * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 + * + * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, + * 避免出现"打开了但什么也选不了"的假入口。 + */ + const openResourceVersionReplacement = useCallback( + async (resource: ProjectResource) => { + const assetId = resource.manifestAssetId; + const version = resolveActiveIterationVersion( + projectVersions, + activeVersionId, + ); + if (!assetId || !version) return; + const source = { versionId: version.versionId, assetId }; + setResourceReplacementError(null); + setResourceReplacementCandidates([]); + setResourceReplacementSource(source); + setResourceReplacementLoading(true); + try { + const result = await readVersionResourceReplacementCandidates({ + projectPath, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + }); + setResourceReplacementCandidates(result.candidates); + setResourceReplacementOpen(true); + } catch (error) { + setResourceReplacementSource(null); + setResourceWorkbenchNotice( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [activeVersionId, projectPath, projectVersions], + ); + const closeResourceVersionReplacement = useCallback(() => { + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + setResourceReplacementError(null); + }, []); + /** + * 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。 + * + * 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。 + * 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现, + * 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。 + */ + const confirmResourceVersionReplacement = useCallback( + async (assetIds: string[]) => { + const source = resourceReplacementSource; + const replacementResourceId = assetIds[0]; + if (!source || !replacementResourceId) { + setResourceReplacementError('请选择一个替换素材'); + return; + } + if (resourceReplacementLoading) return; + setResourceReplacementLoading(true); + setResourceReplacementError(null); + try { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('替换素材需要在客户端内执行'); + } + 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 replaceVersionResource({ + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision: status.revision, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + replacementResourceId, + }); + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + selectActiveVersion(result.versionId); + await reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `version-resource-replacement:${result.versionId}`, + ); + } catch (error) { + setResourceReplacementError( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [ + manifest.projectId, + projectPath, + reloadManifestAfterAssetCommand, + resourceReplacementLoading, + resourceReplacementSource, + ], + ); + const resourceReplacementPickerEntries = useMemo( + () => + resourceReplacementPickerAssets(resources, resourceReplacementCandidates), + [resourceReplacementCandidates, resources], + ); + const resourceReplacementBlockedReasonMap = useMemo( + () => resourceReplacementBlockedReasons(resourceReplacementCandidates), + [resourceReplacementCandidates], + ); /** * 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。 * Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。 @@ -6405,6 +6551,33 @@ export default function ProjectDevelopmentView({ onConfirm={(newFileName) => void confirmResourceRename(newFileName)} /> ) : null} + {/* + 版本级资源替换:复用美术画布的参考图弹窗(单选 + 禁用原因 + 失败原因三个 opt-in prop)。 + 缩略图走 `renderAssetMedia` 的类型占位:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL, + 弹窗里没有同步 `src`,直接给 `` 会挂破图。 + */} + ( + + )} + onCancel={closeResourceVersionReplacement} + onConfirm={(assetIds) => void confirmResourceVersionReplacement(assetIds)} + /> {resourceRecoveryPanelOpen ? (
void; onConfirm: (assetIds: string[]) => void; + /** + * 单选模式:点击即整组替换当前选择,不再渲染「已选」chip 行。 + * 默认 `false`,网页端美术画布的参考图多选行为逐字不变。 + */ + singleSelect?: boolean; + /** + * 被禁用的素材 id → 用户可见的禁用原因。默认空,即所有素材都可选。 + * + * 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。 + */ + assetBlockedReasons?: Readonly>; + /** + * 素材缩略图渲染器。默认 `undefined` → 沿用 ``。 + * + * 宿主(如 AGC 资源工作台)没有同步 `src` 时必须传它:AGC 的预览读取走带 scope 的 + * 原生调度器 + Blob URL,弹窗内取不到,直接给空 `src` 会挂破图。 + */ + renderAssetMedia?: (asset: EditorAsset) => ReactNode; + /** + * 选择对象的中文名词,用于拼弹窗标题与可访问名称。默认「参考图」, + * 即网页端美术画布的现有文案逐字不变。 + */ + selectionNoun?: string; + /** + * 宿主的失败原因(例如后端拒绝了这次替换)。默认 `undefined` → 不渲染。 + * + * 用于在弹窗内说明"为什么这次操作没成功",而不是静默关闭弹窗让用户以为成功了。 + */ + errorMessage?: string | null; }; function assetIcon(asset: EditorAsset) { @@ -46,6 +76,11 @@ export function ImageCanvasProjectAssetPickerDialog({ selectedAssetIds, onCancel, onConfirm, + singleSelect = false, + assetBlockedReasons, + renderAssetMedia, + selectionNoun = '参考图', + errorMessage, }: ImageCanvasProjectAssetPickerDialogProps) { const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); @@ -76,20 +111,24 @@ export function ImageCanvasProjectAssetPickerDialog({ }); function toggleAsset(assetId: string) { - setSelection((current) => - current.includes(assetId) + if (assetBlockedReasons?.[assetId]) return; + setSelection((current) => { + if (singleSelect) { + return current.includes(assetId) ? [] : [assetId]; + } + return current.includes(assetId) ? current.filter((item) => item !== assetId) - : [...current, assetId], - ); + : [...current, assetId]; + }); } return ( onConfirm(selection)} > 确认 @@ -126,13 +165,18 @@ export function ImageCanvasProjectAssetPickerDialog({ } > - {selectedAssets.length > 0 ? ( -
+ {errorMessage ? ( + + {errorMessage} + + ) : null} + {selectedAssets.length > 0 && !singleSelect ? ( +
{selectedAssets.map((asset) => ( ); })}