532c6d51fd
## 背景 本合并请求整合 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>
201 lines
5.2 KiB
TypeScript
201 lines
5.2 KiB
TypeScript
import type { ProjectResource } from './resourceProjectionModel';
|
|
|
|
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
|
|
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 48;
|
|
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
|
|
export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96;
|
|
export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE =
|
|
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY;
|
|
|
|
export type ProjectResourceCardPreviewKind =
|
|
| 'raster-image'
|
|
| 'media-image'
|
|
| 'video'
|
|
| 'audio'
|
|
| 'code'
|
|
| 'document'
|
|
| 'version'
|
|
| 'placeholder';
|
|
|
|
export type ProjectResourceImageDimensions = {
|
|
pixelWidth: number;
|
|
pixelHeight: number;
|
|
};
|
|
|
|
export type ProjectResourceCardPreviewPayload = {
|
|
path: string;
|
|
mediaType: string;
|
|
byteLen: number;
|
|
pixelWidth?: number;
|
|
pixelHeight?: number;
|
|
sourceUrl?: string;
|
|
content?: string;
|
|
};
|
|
|
|
export type ProjectResourceCardPreviewTransportPayload = Omit<
|
|
ProjectResourceCardPreviewPayload,
|
|
'sourceUrl'
|
|
> & {
|
|
dataUrl?: string;
|
|
};
|
|
|
|
export function projectResourceCardPreviewImageDimensions(
|
|
preview: Pick<
|
|
ProjectResourceCardPreviewPayload,
|
|
'pixelWidth' | 'pixelHeight'
|
|
>,
|
|
): ProjectResourceImageDimensions | null {
|
|
const { pixelWidth, pixelHeight } = preview;
|
|
if (pixelWidth === undefined && pixelHeight === undefined) {
|
|
return null;
|
|
}
|
|
if (
|
|
typeof pixelWidth !== 'number' ||
|
|
typeof pixelHeight !== 'number' ||
|
|
!Number.isSafeInteger(pixelWidth) ||
|
|
!Number.isSafeInteger(pixelHeight) ||
|
|
pixelWidth <= 0 ||
|
|
pixelHeight <= 0
|
|
) {
|
|
throw new Error('图片预览像素尺寸无效');
|
|
}
|
|
return { pixelWidth, pixelHeight };
|
|
}
|
|
|
|
export type ProjectResourceCardPreviewCacheEntry = {
|
|
identity: string;
|
|
retainedBytes: number;
|
|
};
|
|
|
|
export type ProjectResourceCardPreviewState =
|
|
| { status: 'idle' }
|
|
| { status: 'loading' }
|
|
| {
|
|
status: 'loaded';
|
|
preview: ProjectResourceCardPreviewPayload;
|
|
}
|
|
| { status: 'failed'; error: string; retryable: boolean };
|
|
|
|
export const IDLE_PROJECT_RESOURCE_CARD_PREVIEW = {
|
|
status: 'idle',
|
|
} as const satisfies ProjectResourceCardPreviewState;
|
|
|
|
function normalizedProjectResourceCardPreviewRetainedBytes(
|
|
retainedBytes: number,
|
|
) {
|
|
return Number.isSafeInteger(retainedBytes) && retainedBytes >= 0
|
|
? retainedBytes
|
|
: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1;
|
|
}
|
|
|
|
export function projectResourceCardPreviewEvictionIdentities(
|
|
entries: readonly ProjectResourceCardPreviewCacheEntry[],
|
|
protectedIdentity: string | null,
|
|
): string[] {
|
|
let retainedCount = entries.length;
|
|
let retainedBytes = entries.reduce(
|
|
(total, entry) =>
|
|
total +
|
|
normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes),
|
|
0,
|
|
);
|
|
const evicted = new Set<string>();
|
|
const overBudget = () =>
|
|
retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT ||
|
|
retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT;
|
|
|
|
for (const entry of entries) {
|
|
if (!overBudget()) {
|
|
break;
|
|
}
|
|
if (entry.identity === protectedIdentity) {
|
|
continue;
|
|
}
|
|
evicted.add(entry.identity);
|
|
retainedCount -= 1;
|
|
retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes(
|
|
entry.retainedBytes,
|
|
);
|
|
}
|
|
|
|
if (overBudget() && protectedIdentity) {
|
|
const protectedEntry = entries.find(
|
|
(entry) => entry.identity === protectedIdentity,
|
|
);
|
|
if (protectedEntry && !evicted.has(protectedEntry.identity)) {
|
|
evicted.add(protectedEntry.identity);
|
|
}
|
|
}
|
|
|
|
return Array.from(evicted);
|
|
}
|
|
|
|
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
|
|
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
|
|
const videoExtension = /\.(mp4|webm|mov)$/iu;
|
|
|
|
export function projectResourceCardPreviewKind(
|
|
resource: ProjectResource,
|
|
): ProjectResourceCardPreviewKind {
|
|
if (resource.category === 'version') {
|
|
return 'version';
|
|
}
|
|
if (resource.category === 'code') {
|
|
return 'code';
|
|
}
|
|
if (resource.category === 'document') {
|
|
return 'document';
|
|
}
|
|
if (resource.category === 'audio') {
|
|
return 'audio';
|
|
}
|
|
const mediaType = resource.mediaType.trim().toLowerCase();
|
|
if (mediaType.startsWith('video/') || videoExtension.test(resource.path)) {
|
|
return 'video';
|
|
}
|
|
if (
|
|
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
|
|
mediaType,
|
|
) ||
|
|
rasterImageExtension.test(resource.path)
|
|
) {
|
|
return 'raster-image';
|
|
}
|
|
if (
|
|
mediaType.startsWith('image/') ||
|
|
extendedImageExtension.test(resource.path)
|
|
) {
|
|
return 'media-image';
|
|
}
|
|
return 'placeholder';
|
|
}
|
|
|
|
export function projectResourceCardPreviewIdentity(input: {
|
|
projectPath: string;
|
|
projectId: string;
|
|
previewVersion?: string;
|
|
resource: ProjectResource;
|
|
}) {
|
|
return JSON.stringify([
|
|
input.projectPath,
|
|
input.projectId,
|
|
input.resource.id,
|
|
input.resource.category,
|
|
input.resource.path,
|
|
input.resource.mediaType,
|
|
input.previewVersion ?? '',
|
|
]);
|
|
}
|
|
|
|
export function summarizeProjectResourceDocument(content: string) {
|
|
return content
|
|
.replace(/!\[[^\]]*\]\([^)]*\)/gu, ' ')
|
|
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
|
|
.replace(/<[^>]*>/gu, ' ')
|
|
.replace(/[`*_~>#|{}[\]]/gu, ' ')
|
|
.replace(/^\s*[-+]\s+/gmu, '')
|
|
.replace(/\s+/gu, ' ')
|
|
.trim()
|
|
.slice(0, 180);
|
|
}
|