修复 AGC 运行预览尺寸抖动 (#252)
Project CI / Repository checks (push) Successful in 2m42s
Project CI / Frontend tests (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 6m59s
Project CI / Native shell tests (push) Successful in 17m33s

## 问题

AGC 项目运行页的本地预览会根据 iframe 上报内容尺寸调整 iframe viewport;部分使用 `100vh`、百分比或响应式布局的游戏会把宿主调整后的 viewport 再反映为新的内容尺寸,形成反馈环并在两个缩放档位之间持续抖动。

附件工程和录屏已确认可触发该问题,普通浏览器直接打开不受影响。

## 落地方案

- 补充宿主 viewport 变更与真实内容变更的区分,忽略由宿主首次应用 fit 引起的回灌尺寸;
- 保留同一稳定 viewport 下真实动态内容变化后的重新适配;
- 增加反馈环收敛、动态内容变化和陈旧消息回归;
- 同步 AGC 运行预览权威文档和项目记忆。

## 验证计划

- 附件 `gameagent-2873e5ac` 在 AGC WebView 中稳定显示;
- 相关 TypeScript 单测与类型检查;
- preview Rust 定向测试;
- `npm run check:encoding`、`git diff --check`。

Fixes #250

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/252
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
This commit was merged in pull request #252.
This commit is contained in:
2026-09-03 10:35:23 +08:00
committed by 段舒康
parent a465e481e3
commit 258501918f
4 changed files with 220 additions and 16 deletions
@@ -18,6 +18,11 @@ export type LocalGamePreviewFitLayout = {
scale: number;
};
type LocalGamePreviewViewportSize = {
width: number;
height: number;
};
export type LocalGamePreviewLike = {
status?: string | null;
url?: string | null;
@@ -91,13 +96,30 @@ export function resolveLocalGamePreviewFitLayout(
export function resolveLocalGamePreviewContentSizeUpdate(
current: LocalGamePreviewContentSize | null,
next: LocalGamePreviewContentSize,
nativeViewport: { width: number; height: number },
appliedViewport: { width: number; height: number },
nativeViewport: LocalGamePreviewViewportSize,
appliedViewport: LocalGamePreviewViewportSize,
previousReportedViewport: LocalGamePreviewViewportSize | null = null,
): LocalGamePreviewContentSize | null {
const reportsViewport = (viewport: { width: number; height: number }) =>
Math.abs(next.viewportWidth - viewport.width) < 1 &&
Math.abs(next.viewportHeight - viewport.height) < 1;
if (!reportsViewport(nativeViewport) && !reportsViewport(appliedViewport)) {
const reportsNativeViewport = previewReportMatchesViewport(
next,
nativeViewport,
);
const reportsAppliedViewport = previewReportMatchesViewport(
next,
appliedViewport,
);
if (!reportsNativeViewport && !reportsAppliedViewport) {
return current;
}
const followsViewportChange =
previousReportedViewport !== null &&
!previewReportMatchesViewport(next, previousReportedViewport);
if (
current &&
reportsAppliedViewport &&
!reportsNativeViewport &&
followsViewportChange
) {
return current;
}
if (
@@ -114,6 +136,16 @@ export function resolveLocalGamePreviewContentSizeUpdate(
};
}
function previewReportMatchesViewport(
report: LocalGamePreviewContentSize,
viewport: LocalGamePreviewViewportSize,
) {
return (
Math.abs(report.viewportWidth - viewport.width) < 1 &&
Math.abs(report.viewportHeight - viewport.height) < 1
);
}
export function LocalGamePreviewFrame({
preview,
title,
@@ -128,6 +160,9 @@ export function LocalGamePreviewFrame({
const iframeRef = useRef<HTMLIFrameElement>(null);
const measuredContainerSizeRef = useRef({ width: 1, height: 1 });
const contentSizeRef = useRef<LocalGamePreviewContentSize | null>(null);
const reportedViewportSizeRef = useRef<LocalGamePreviewViewportSize | null>(
null,
);
const [containerSize, setContainerSize] = useState({ width: 1, height: 1 });
const [contentSize, setContentSize] =
useState<LocalGamePreviewContentSize | null>(null);
@@ -150,10 +185,6 @@ export function LocalGamePreviewFrame({
}
measuredContainerSizeRef.current = next;
setContainerSize(next);
if (contentSizeRef.current) {
contentSizeRef.current = null;
setContentSize(null);
}
};
update();
if (typeof window.ResizeObserver === 'function') {
@@ -166,6 +197,7 @@ export function LocalGamePreviewFrame({
}, [embeddedUrl]);
useEffect(() => {
reportedViewportSizeRef.current = null;
if (contentSizeRef.current) {
contentSizeRef.current = null;
setContentSize(null);
@@ -190,12 +222,22 @@ export function LocalGamePreviewFrame({
nativeViewport,
current,
);
const reportsCurrentViewport =
previewReportMatchesViewport(next, nativeViewport) ||
previewReportMatchesViewport(next, appliedViewport);
const resolved = resolveLocalGamePreviewContentSizeUpdate(
current,
next,
nativeViewport,
appliedViewport,
reportedViewportSizeRef.current,
);
if (reportsCurrentViewport) {
reportedViewportSizeRef.current = {
width: next.viewportWidth,
height: next.viewportHeight,
};
}
if (resolved === current) return;
contentSizeRef.current = resolved;
setContentSize(resolved);
@@ -1,13 +1,173 @@
import { describe, expect, it } from 'vitest';
// @vitest-environment jsdom
import { act, render } from '@testing-library/react';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
LocalGamePreviewFrame,
parseLocalGamePreviewContentSize,
resolveLocalGamePreviewContentSizeUpdate,
resolveLocalGamePreviewFitLayout,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
describe('local game preview viewport fitting', () => {
it('does not reset the fitted iframe to native size while its container resizes', () => {
let containerRect = { width: 800, height: 500 };
let resizeCallback: ResizeObserverCallback | null = null;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
...containerRect,
x: 0,
y: 0,
top: 0,
right: containerRect.width,
bottom: containerRect.height,
left: 0,
toJSON: () => ({}),
}) as DOMRect,
);
const previousResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
const view = render(
createElement(LocalGamePreviewFrame, {
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
title: 'preview',
}),
);
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
origin: 'http://127.0.0.1:1234',
source: iframe.contentWindow,
data: {
type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
},
}),
);
});
expect(iframe.style.height).toBe('835px');
containerRect = { width: 1000, height: 600 };
act(() => {
if (!resizeCallback) throw new Error('ResizeObserver was not registered');
resizeCallback([], {} as ResizeObserver);
});
expect(iframe.style.width).toBe('1000px');
expect(iframe.style.height).toBe('835px');
view.unmount();
window.ResizeObserver = previousResizeObserver;
rectSpy.mockRestore();
});
it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => {
const nativeViewport = { width: 1200, height: 700 };
const appliedViewport = { width: 1200, height: 1000 };
const current = {
contentWidth: 1200,
contentHeight: 1000,
viewportWidth: 1200,
viewportHeight: 700,
};
const firstAppliedViewportReport = {
contentWidth: 1200,
contentHeight: 700,
viewportWidth: 1200,
viewportHeight: 1000,
};
expect(
resolveLocalGamePreviewContentSizeUpdate(
current,
firstAppliedViewportReport,
nativeViewport,
appliedViewport,
nativeViewport,
),
).toBe(current);
});
it('does not let a delayed native report restart the fitted viewport loop', () => {
const nativeViewport = { width: 1200, height: 700 };
const appliedViewport = { width: 1200, height: 1000 };
const current = {
contentWidth: 1200,
contentHeight: 1000,
viewportWidth: 1200,
viewportHeight: 700,
};
const delayedNativeReport = {
contentWidth: 1200,
contentHeight: 1000,
viewportWidth: 1200,
viewportHeight: 700,
};
expect(
resolveLocalGamePreviewContentSizeUpdate(
current,
delayedNativeReport,
nativeViewport,
appliedViewport,
appliedViewport,
),
).toBe(current);
});
it('keeps the current fit while a resized container applies its next viewport', () => {
const resizedContainer = { width: 1000, height: 600 };
const current = {
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
};
const appliedViewport = resolveLocalGamePreviewFitLayout(
resizedContainer,
current,
);
const resizedViewportReport = {
contentWidth: 1000,
contentHeight: 818,
viewportWidth: appliedViewport.width,
viewportHeight: appliedViewport.height,
};
expect(appliedViewport).toEqual({
width: 1000,
height: 835,
scale: 600 / 835,
});
expect(
resolveLocalGamePreviewContentSizeUpdate(
current,
resizedViewportReport,
resizedContainer,
appliedViewport,
{ width: 800, height: 835 },
),
).toBe(current);
});
it('keeps a game at native size when its content fits', () => {
expect(
resolveLocalGamePreviewFitLayout(
@@ -55,7 +215,7 @@ describe('local game preview viewport fitting', () => {
).toEqual(report);
});
it('accepts changed content after the iframe has adopted the first fit viewport', () => {
it('accepts changed content after the fitted iframe viewport has stabilized', () => {
const nativeViewport = { width: 1200, height: 700 };
const current = {
contentWidth: 1200,
@@ -79,6 +239,7 @@ describe('local game preview viewport fitting', () => {
width: 1200,
height: 1000,
},
{ width: 1200, height: 1000 },
),
).toEqual({
contentWidth: 1200,
@@ -88,7 +249,7 @@ describe('local game preview viewport fitting', () => {
});
});
it('shrinks the fitted frame when content becomes shorter inside the applied viewport', () => {
it('shrinks the fitted frame when content becomes shorter inside a stable applied viewport', () => {
const nativeViewport = { width: 1200, height: 700 };
const current = {
contentWidth: 1200,
@@ -108,6 +269,7 @@ describe('local game preview viewport fitting', () => {
changed,
nativeViewport,
{ width: 1200, height: 1000 },
{ width: 1200, height: 1000 },
);
expect(updated).toEqual({
contentWidth: 1200,
@@ -7794,8 +7794,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-08-23 AI 游戏运行视窗按预览文档实际尺寸自适应
- 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;首次缩放后仍接受内容宽高真实变化,重复内容尺寸或仅 viewport 回灌不更新状态,容器 resize 后重新以原生视口测量。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
- 验证:前端纯函数覆盖无需缩放、纵向超高缩放、首次缩放后的增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以桌面最小窗口和更高窗口人工确认完整画面、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
- 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。
## 2026-08-23 Direct Codex 显式重生成与切片一等资源
@@ -561,7 +561,7 @@ game-project/
- 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;首次适配后仍接受内容宽高的真实变化,但仅 viewport 回灌或重复内容尺寸不更新 React 状态。容器 resize 后回到原生视口重新测量放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。