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>
236 lines
6.8 KiB
TypeScript
236 lines
6.8 KiB
TypeScript
import {
|
|
type RefObject,
|
|
useCallback,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import type {
|
|
ProjectResourceCanvasLayoutMode,
|
|
ProjectResourceCanvasSection,
|
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel';
|
|
import {
|
|
clampProjectResourceSectionHeight,
|
|
clampProjectResourceSectionZoom,
|
|
defaultProjectResourceSectionHeight,
|
|
type ProjectResourceSectionHeightAction,
|
|
projectResourceSectionHeightBounds,
|
|
projectResourceSectionHeightKey,
|
|
type ProjectResourceSectionZoomAction,
|
|
RESOURCE_SECTION_ZOOM_DEFAULT,
|
|
RESOURCE_SECTION_ZOOM_MAX,
|
|
RESOURCE_SECTION_ZOOM_MIN,
|
|
updateProjectResourceSectionHeight,
|
|
updateProjectResourceSectionZoom,
|
|
} from './resourceSectionHeightModel';
|
|
|
|
const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
|
|
'dependency',
|
|
'type',
|
|
];
|
|
const RESOURCE_SECTIONS = RESOURCE_CANVAS_SECTION_ORDER;
|
|
|
|
export type ProjectResourceSectionHeightState = {
|
|
height: number;
|
|
min: number;
|
|
max: number;
|
|
defaultHeight: number;
|
|
atMin: boolean;
|
|
atMax: boolean;
|
|
atDefault: boolean;
|
|
zoom: number;
|
|
atMinZoom: boolean;
|
|
atMaxZoom: boolean;
|
|
atDefaultZoom: boolean;
|
|
};
|
|
|
|
export function useProjectResourceSectionHeights(input: {
|
|
projectId: string;
|
|
mode: ProjectResourceCanvasLayoutMode;
|
|
canvasRef: RefObject<HTMLDivElement | null>;
|
|
enabled: boolean;
|
|
}) {
|
|
const { canvasRef, enabled, mode, projectId } = input;
|
|
const [bounds, setBounds] = useState(() =>
|
|
projectResourceSectionHeightBounds(0),
|
|
);
|
|
const [sessionHeights, setSessionHeights] = useState<Map<string, number>>(
|
|
() => new Map(),
|
|
);
|
|
const [sessionZooms, setSessionZooms] = useState<Map<string, number>>(
|
|
() => new Map(),
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!enabled) {
|
|
return undefined;
|
|
}
|
|
let frameId: number | null = null;
|
|
const measure = () => {
|
|
frameId = null;
|
|
const canvasHeight = canvasRef.current?.clientHeight ?? 0;
|
|
if (canvasHeight <= 0) {
|
|
return;
|
|
}
|
|
const nextBounds = projectResourceSectionHeightBounds(canvasHeight);
|
|
setBounds((current) =>
|
|
current.min === nextBounds.min && current.max === nextBounds.max
|
|
? current
|
|
: nextBounds,
|
|
);
|
|
setSessionHeights((current) => {
|
|
let next: Map<string, number> | null = null;
|
|
for (const layoutMode of RESOURCE_LAYOUT_MODES) {
|
|
for (const section of RESOURCE_SECTIONS) {
|
|
const key = projectResourceSectionHeightKey({
|
|
projectId,
|
|
mode: layoutMode,
|
|
section,
|
|
});
|
|
const height = current.get(key);
|
|
if (height === undefined) {
|
|
continue;
|
|
}
|
|
const clamped = clampProjectResourceSectionHeight(
|
|
height,
|
|
nextBounds,
|
|
);
|
|
if (clamped !== height) {
|
|
next ??= new Map(current);
|
|
next.set(key, clamped);
|
|
}
|
|
}
|
|
}
|
|
return next ?? current;
|
|
});
|
|
};
|
|
const scheduleMeasure = () => {
|
|
if (frameId !== null) {
|
|
return;
|
|
}
|
|
frameId = window.requestAnimationFrame(measure);
|
|
};
|
|
measure();
|
|
window.addEventListener('resize', scheduleMeasure);
|
|
return () => {
|
|
if (frameId !== null) {
|
|
window.cancelAnimationFrame(frameId);
|
|
}
|
|
window.removeEventListener('resize', scheduleMeasure);
|
|
};
|
|
}, [canvasRef, enabled, projectId]);
|
|
|
|
const sectionStates = useMemo(() => {
|
|
const defaultHeight = defaultProjectResourceSectionHeight(bounds);
|
|
return new Map(
|
|
RESOURCE_SECTIONS.map((section) => {
|
|
const key = projectResourceSectionHeightKey({
|
|
projectId,
|
|
mode,
|
|
section,
|
|
});
|
|
const height = clampProjectResourceSectionHeight(
|
|
sessionHeights.get(key) ?? defaultHeight,
|
|
bounds,
|
|
);
|
|
const zoom = clampProjectResourceSectionZoom(
|
|
sessionZooms.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
|
|
);
|
|
return [
|
|
section,
|
|
{
|
|
height,
|
|
min: bounds.min,
|
|
max: bounds.max,
|
|
defaultHeight,
|
|
atMin: height === bounds.min,
|
|
atMax: height === bounds.max,
|
|
atDefault: height === defaultHeight,
|
|
zoom,
|
|
atMinZoom: zoom === RESOURCE_SECTION_ZOOM_MIN,
|
|
atMaxZoom: zoom === RESOURCE_SECTION_ZOOM_MAX,
|
|
atDefaultZoom: zoom === RESOURCE_SECTION_ZOOM_DEFAULT,
|
|
} satisfies ProjectResourceSectionHeightState,
|
|
] as const;
|
|
}),
|
|
);
|
|
}, [bounds, mode, projectId, sessionHeights, sessionZooms]);
|
|
|
|
const updateSectionHeight = useCallback(
|
|
(
|
|
section: ProjectResourceCanvasSection,
|
|
action: ProjectResourceSectionHeightAction,
|
|
) => {
|
|
const key = projectResourceSectionHeightKey({ projectId, mode, section });
|
|
setSessionHeights((current) => {
|
|
const currentHeight = clampProjectResourceSectionHeight(
|
|
current.get(key) ?? defaultProjectResourceSectionHeight(bounds),
|
|
bounds,
|
|
);
|
|
const nextHeight = updateProjectResourceSectionHeight(
|
|
currentHeight,
|
|
action,
|
|
bounds,
|
|
);
|
|
if (nextHeight === currentHeight) {
|
|
return current;
|
|
}
|
|
const next = new Map(current);
|
|
next.set(key, nextHeight);
|
|
return next;
|
|
});
|
|
},
|
|
[bounds, mode, projectId],
|
|
);
|
|
|
|
const setSectionZoom = useCallback(
|
|
(section: ProjectResourceCanvasSection, zoom: number) => {
|
|
const key = projectResourceSectionHeightKey({ projectId, mode, section });
|
|
const nextZoom = clampProjectResourceSectionZoom(zoom);
|
|
setSessionZooms((current) => {
|
|
const currentZoom = clampProjectResourceSectionZoom(
|
|
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
|
|
);
|
|
if (nextZoom === currentZoom) {
|
|
return current;
|
|
}
|
|
const next = new Map(current);
|
|
next.set(key, nextZoom);
|
|
return next;
|
|
});
|
|
},
|
|
[mode, projectId],
|
|
);
|
|
|
|
const updateSectionZoom = useCallback(
|
|
(
|
|
section: ProjectResourceCanvasSection,
|
|
action: ProjectResourceSectionZoomAction,
|
|
) => {
|
|
const key = projectResourceSectionHeightKey({ projectId, mode, section });
|
|
setSessionZooms((current) => {
|
|
const currentZoom = clampProjectResourceSectionZoom(
|
|
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
|
|
);
|
|
const nextZoom = updateProjectResourceSectionZoom(currentZoom, action);
|
|
if (nextZoom === currentZoom) {
|
|
return current;
|
|
}
|
|
const next = new Map(current);
|
|
next.set(key, nextZoom);
|
|
return next;
|
|
});
|
|
},
|
|
[mode, projectId],
|
|
);
|
|
|
|
return {
|
|
sectionStates,
|
|
setSectionZoom,
|
|
updateSectionHeight,
|
|
updateSectionZoom,
|
|
};
|
|
}
|