Files
Genarrative/packages/image-canvas-core/src/ports.ts
T
suzmii 532c6d51fd
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s
完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景

本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。

当前远端比较基线:

- base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d`
- head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472`
- 相对 base:28 commits、33 files(`+3746 / -1333`)

## 主要改动

### 资源管理分页自由画布

- 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。
- `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。
- 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。
- 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。
- 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。
- 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。

### 图片持续精修与生成事务

- 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。
- 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。
- 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。
- 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。
- 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。

### 合同与文档

- `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。
- 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。
- 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。

## 验证

CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功:

- [x] Repository checks
- [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests)
- [x] Backend tests
- [x] Native shell tests

另有本地定向检查:

- [x] `npm run agc:typecheck`
- [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归
- [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试
- [x] `cargo fmt --check`
- [x] `npm run check:encoding`
- [x] `git diff --check`

## 评审 provenance

- review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。
- review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。
- 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。
- 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。

## 影响范围

- 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。
- 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。
- 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-24 20:42:58 +08:00

371 lines
9.7 KiB
TypeScript

export type ImageCanvasHostKind = 'web' | 'tauri';
export type ImageCanvasHostCapabilities = {
account: boolean;
wallet: boolean;
cloudAssetLibrary: boolean;
localProject: boolean;
externalEditorGeneration: boolean;
advancedBackgroundRemoval: boolean;
};
export type ImageCanvasHostScope = {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
export type ImageCanvasHostResult<T> =
| { status: 'ok'; value: T }
| {
status: 'unsupported-capability';
capability: keyof ImageCanvasHostCapabilities;
message: string;
}
| {
status: 'conflict';
conflictKind: 'project-identity' | 'host-revision' | 'draft-revision';
draft: ImageCanvasDraft | null;
hostRevision: string | null;
}
| { status: 'failed'; code: string; message: string };
export type ImageCanvasMediaRef =
| { kind: 'project-asset'; assetId: string }
| {
kind: 'draft-media';
mediaId: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
sha256: string;
byteLength: number;
pixelWidth: number;
pixelHeight: number;
}
| { kind: 'host-resource'; resourceId: string };
export type ImageCanvasDraftLayer = {
layerId: string;
resourceId: string;
title: string;
mediaRef: ImageCanvasMediaRef;
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
groupId: string | null;
hidden: boolean;
locked: boolean;
flipX: boolean;
flipY: boolean;
};
export type ImageCanvasDraftCanvas = {
viewport: { x: number; y: number; scale: number };
backgroundColor: string;
layers: ImageCanvasDraftLayer[];
selectedLayerIds: string[];
primarySelectedLayerId: string | null;
};
export type ImageCanvasDraft = {
schemaVersion: 'game-creator-asset-canvas-draft.v1';
draftId: string;
projectId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
sourceResourceId: string | null;
revision: number;
status:
| 'editing'
| 'generating'
| 'commit-prepared'
| 'committed'
| 'cancelled'
| 'reconciliation-required';
canvas: ImageCanvasDraftCanvas;
generations: ImageCanvasGenerationRecord[];
pendingCommit: {
commitId: string;
idempotencyKey?: string;
requestFingerprint: string;
} | null;
lastCommit: {
commitId: string;
idempotencyKey?: string;
assetId: string;
eventId: string;
committedProjectRevision: number;
sourceLayerId: string;
mediaSha256: string;
} | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasGenerationPlaceholder = {
x: number;
y: number;
width: number;
height: number;
};
export type ImageCanvasGenerationRecord = {
generationId: string;
intentId: string;
phase:
| 'generation-accepted'
| 'generation-running'
| 'remote-completed'
| 'media-downloaded'
| 'candidate-ready'
| 'asset-durable-committed'
| 'failed'
| 'reconciliation-required';
referenceResourceIds: string[];
outputAssetId: string | null;
sourceLayerId: string | null;
placeholder: ImageCanvasGenerationPlaceholder | null;
errorCode: string | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasGenerationProgressPhase =
| 'confirmation-required'
| ImageCanvasGenerationRecord['phase']
| 'manifest-projected'
| 'layout-ready'
| 'selected';
export type ImageCanvasGenerationProgress = {
intentId: string;
generationId: string;
draftRevision?: number;
phase: ImageCanvasGenerationProgressPhase;
progress: number | null;
errorCode: string | null;
};
export type ImageCanvasGenerationServiceIdentityConfirmation = {
generationId: string;
operationId: string | null;
operationState: string;
serviceOrigin: string;
challenge: string;
expiresAt: number;
};
export type ImageCanvasGenerationFailureDisposition =
| 'failed'
| 'reconciliation-required'
| 'not-started'
| 'already-terminal';
export type ImageCanvasHostInputImage = {
name: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
bytes: Uint8Array;
};
export type ImageCanvasHostImage = {
mediaRef: ImageCanvasMediaRef;
previewUrl: string;
resourceId: string | null;
};
export type ImageCanvasLocalImportResult =
| { status: 'cancelled' }
| {
status: 'imported';
draft: ImageCanvasDraft;
importedLayerIds: string[];
};
export interface ImageCanvasProjectPort {
loadDraft(
scope: ImageCanvasHostScope,
): Promise<ImageCanvasHostResult<ImageCanvasDraft | null>>;
createDraft(
scope: ImageCanvasHostScope,
): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
updateDraft(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
status: 'editing' | 'generating' | 'cancelled';
canvas: ImageCanvasDraft['canvas'];
generations: ImageCanvasGenerationRecord[];
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
acknowledgeCandidateLayers(input: {
scope: ImageCanvasHostScope;
layerIds: string[];
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
discardDraft(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
}
export interface ImageCanvasAssetPort {
importLocalImages(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
viewportSize: { width: number; height: number };
}): Promise<ImageCanvasHostResult<ImageCanvasLocalImportResult>>;
importImages(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
images: ImageCanvasHostInputImage[];
}): Promise<ImageCanvasHostResult<ImageCanvasHostImage[]>>;
exportImage(input: {
scope: ImageCanvasHostScope;
name: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
quality: number | null;
bytes: Uint8Array;
}): Promise<
ImageCanvasHostResult<{
disposition: 'downloaded' | 'saved';
byteLength: number;
}>
>;
}
export interface ImageCanvasGenerationPort {
settleGenerationFailure(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
generationId: string;
intentId: string;
errorCode: string;
reconciliationRequired: boolean;
}): Promise<
ImageCanvasHostResult<{
disposition: ImageCanvasGenerationFailureDisposition;
draft: ImageCanvasDraft;
}>
>;
archiveFailedGeneration(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
generationId: string;
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
generateImage(input: {
scope: ImageCanvasHostScope;
expectedHostRevision: string;
expectedDraftRevision: number;
intentId: string;
generationId: string;
idempotencyKey: string;
commitId: string;
commitIdempotencyKey: string;
prompt: string;
aspectRatio: '1:1' | '2:3' | '3:2' | '9:16' | '16:9';
imageSize: '0.5K' | '1K' | '2K';
assetKind: string;
assetName: string;
referenceResourceIds: string[];
sourceLayerId: string | null;
placeholder: ImageCanvasGenerationPlaceholder | null;
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
}): Promise<
ImageCanvasHostResult<{
generation: ImageCanvasGenerationRecord;
images: ImageCanvasHostImage[];
draft: ImageCanvasDraft;
}>
>;
recoverImages(input: {
scope: ImageCanvasHostScope;
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
}): Promise<
ImageCanvasHostResult<{
resumedGenerationIds: string[];
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
}>
>;
removeBackground(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
generationId: string;
idempotencyKey: string;
sourceLayerId: string;
}): Promise<
ImageCanvasHostResult<{
generation: ImageCanvasGenerationRecord;
images: ImageCanvasHostImage[];
}>
>;
}
export interface ImageCanvasCompletionPort {
commitSelectedCandidate(input: {
scope: ImageCanvasHostScope;
expectedHostRevision: string;
expectedDraftRevision: number;
commitId: string;
idempotencyKey: string;
sourceLayerId: string;
name: string;
assetKind: string;
referenceResourceIds: string[];
}): Promise<
ImageCanvasHostResult<{
resourceId: string;
assetId?: string;
projectId?: string;
commitId?: string;
committedProjectRevision?: number;
draftRevision: number;
hostRevision: string;
commitStatus?: 'committed' | 'already-committed';
manifest?: unknown;
eventId?: string;
}>
>;
commitImage(input: {
scope: ImageCanvasHostScope;
expectedHostRevision: string;
expectedDraftRevision: number;
commitId: string;
idempotencyKey: string;
name: string;
assetKind: string;
referenceResourceIds: string[];
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
bytes: Uint8Array;
}): Promise<
ImageCanvasHostResult<{
resourceId: string;
assetId?: string;
projectId?: string;
commitId?: string;
committedProjectRevision?: number;
draftRevision: number;
hostRevision: string;
commitStatus?: 'committed' | 'already-committed';
manifest?: unknown;
eventId?: string;
}>
>;
}
export type ImageCanvasHostPort = {
readonly kind: ImageCanvasHostKind;
readonly capabilities: ImageCanvasHostCapabilities;
project: ImageCanvasProjectPort;
asset: ImageCanvasAssetPort;
generation: ImageCanvasGenerationPort;
completion: ImageCanvasCompletionPort;
};
export function unsupportedImageCanvasCapability(
capability: keyof ImageCanvasHostCapabilities,
message: string,
): ImageCanvasHostResult<never> {
return { status: 'unsupported-capability', capability, message };
}