From 79cf932cba4d38d7e256a685bda1388aeb475d58 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 9 Sep 2026 15:55:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E6=9C=AC=E8=BD=AC=E5=9C=BA=E5=BF=AB=E7=85=A7=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=A2=84=E8=A7=88=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 快照克隆原先直接复制 blob: 对象 URL,源卡片卸载或预览缓存驱逐后该 URL 被 revoke,克隆里的图片随即失效,表现为进入/退出子画布时卡片框在动而图片不跟着展开和复原 对 blob: 来源且已解码的图片在克隆时栅格化为 data URL 冻结,与既有视频快照处理保持一致 补充 resourceBookController 快照图片冻结回归用例 --- .../resourceBookController.ts | 24 +++++++++++ .../tests/resourceBookController.test.ts | 40 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts index db688f51a..9998161e2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts @@ -23,6 +23,26 @@ function validRect(rect: Snapshot['rect']) { ); } +// 卡片预览用 object URL 渲染;源节点卸载或预览缓存驱逐会 revoke 掉它。 +// 快照必须自带像素,否则展开/复原过程中图片会直接消失。 +function freezeBlobImageSource(image: HTMLImageElement) { + const source = image.currentSrc || image.getAttribute('src') || ''; + if (!source.startsWith('blob:')) return null; + if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) + return null; + try { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d'); + if (!context) return null; + context.drawImage(image, 0, 0); + return canvas.toDataURL(); + } catch { + return null; + } +} + // Freeze presentation only. Clones cannot expose duplicate controls, replay // media, or depend on CSS selectors belonging to the old ancestor tree. function clonePresentation( @@ -74,6 +94,10 @@ function clonePresentation( copy.style.animation = 'none'; copy.style.pointerEvents = 'none'; copy.setAttribute('tabindex', '-1'); + if (original instanceof HTMLImageElement) { + const frozen = freezeBlobImageSource(original); + if (frozen) copy.setAttribute('src', frozen); + } if (copy instanceof HTMLMediaElement) { copy.removeAttribute('src'); copy.querySelectorAll('source').forEach((item) => item.remove()); diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index 189848df8..1b2521b90 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -23,7 +23,7 @@ function rect(left = 0, top = 0, width = 100, height = 80): DOMRect { function fixture() { const root = document.createElement('div'); root.innerHTML = `
-
+
`; document.body.append(root); vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); @@ -100,6 +100,44 @@ describe('resource book FLIP controller', () => { controller.dispose(); }); + it('freezes blob-backed card previews into the snapshot', () => { + const { root, measure } = setup(); + const image = root.querySelector( + '.game-resource-card img', + )!; + Object.defineProperty(image, 'complete', { + configurable: true, + value: true, + }); + Object.defineProperty(image, 'naturalWidth', { + configurable: true, + value: 8, + }); + Object.defineProperty(image, 'naturalHeight', { + configurable: true, + value: 6, + }); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( + 'data:image/png;base64,FROZEN', + ); + + const controller = createResourceBookTransitionController(); + const token = controller.begin(root); + measure.mockReturnValue(rect(150, 100, 200, 160)); + controller.play(root, token, () => undefined); + + const snapshotImage = root.querySelector( + '.game-resource-book-transition-layer img', + ); + expect(snapshotImage?.getAttribute('src')).toBe( + 'data:image/png;base64,FROZEN', + ); + controller.dispose(); + }); + it('retargets from the current overlay geometry and ignores old completion', async () => { const { root, measure, flights } = setup(); const controller = createResourceBookTransitionController();