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();