diff --git a/common/changes/@visactor/vtable/feat-video-first-frame-snapshot_2026-07-06-15-35.json b/common/changes/@visactor/vtable/feat-video-first-frame-snapshot_2026-07-06-15-35.json new file mode 100644 index 0000000000..81125dc551 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-video-first-frame-snapshot_2026-07-06-15-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "Support rendering video cells from a first-frame canvas snapshot to release video resources after loading.", + "type": "minor" + } + ], + "packageName": "@visactor/vtable", + "email": "keepjog@163.com" +} diff --git a/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts new file mode 100644 index 0000000000..2080a366fb --- /dev/null +++ b/packages/vtable/__tests__/cell-type/video-cell-first-frame.test.ts @@ -0,0 +1,240 @@ +// @ts-nocheck +import { createVideoCellGroup } from '../../src/scenegraph/group-creater/cell-type/video-cell'; +import { updateImageCellContentWhileResize } from '../../src/scenegraph/group-creater/cell-type/image-cell'; +import { registerForVrender } from '../../src/vrender'; +import * as icons from '../../src/icons'; + +global.__VERSION__ = 'none'; + +registerForVrender(); + +describe('video cell first frame snapshot', () => { + const originalCreateElement = document.createElement.bind(document); + let createdVideo: HTMLVideoElement; + let drawImage: jest.Mock; + + function createTable(customConfig?: Record) { + return { + options: { + customConfig + }, + _getCellStyle: jest.fn(() => ({})), + getCellValue: jest.fn(() => 'https://example.com/video.mp4'), + colCount: 1, + rowCount: 1, + theme: { + cellInnerBorder: true, + frameStyle: {} + }, + getCellIcons: jest.fn(), + scenegraph: { + updateNextFrame: jest.fn(), + highPerformanceGetCell: jest.fn(), + getCell: jest.fn() + } + }; + } + + function createCell(customConfig?: Record, size = { width: 200, height: 120 }) { + const table = createTable(customConfig); + const cellGroup = createVideoCellGroup( + undefined, + 0, + 0, + 0, + 0, + size.width, + size.height, + false, + false, + [0, 0, 0, 0], + 'left', + 'middle', + false, + table, + { + group: {}, + text: {} + }, + undefined, + false + ); + table.scenegraph.getCell.mockReturnValue(cellGroup); + + return { + table, + cellGroup, + image: cellGroup.getChildByName('image', true), + video: createdVideo + }; + } + + beforeEach(() => { + drawImage = jest.fn(); + jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage + } as any); + jest.spyOn(document, 'createElement').mockImplementation((tagName: string, options?: ElementCreationOptions) => { + const element = originalCreateElement(tagName, options); + if (tagName.toLowerCase() === 'video') { + createdVideo = element as HTMLVideoElement; + Object.defineProperties(createdVideo, { + videoWidth: { + value: 640, + configurable: true + }, + videoHeight: { + value: 360, + configurable: true + }, + pause: { + value: jest.fn(), + configurable: true + }, + load: { + value: jest.fn(), + configurable: true + } + }); + } + return element; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('keeps existing video rendering when first frame snapshot is not enabled', () => { + const { image, video } = createCell(); + + video.dispatchEvent(new Event('loadeddata')); + + expect(image.attribute.image).toBe(video); + expect(drawImage).not.toHaveBeenCalled(); + expect(video.pause).not.toHaveBeenCalled(); + expect(video.load).not.toHaveBeenCalled(); + }); + + it('uses a canvas snapshot and releases the video when enabled', () => { + const { image, table, video } = createCell({ + videoFirstFrameSnapshot: true, + videoFirstFrameMaxCanvasSize: 128 + }); + + expect(video.getAttribute('preload')).toBe('auto'); + expect(video.getAttribute('src')).toBe('https://example.com/video.mp4'); + + video.dispatchEvent(new Event('loadeddata')); + + expect(drawImage).toHaveBeenCalledWith(video, 0, 0, 128, 77); + expect(image.attribute.image).toBeInstanceOf(HTMLCanvasElement); + expect((image.attribute.image as HTMLCanvasElement).width).toBe(128); + expect((image.attribute.image as HTMLCanvasElement).height).toBe(77); + expect(video.pause).toHaveBeenCalledTimes(1); + expect(video.hasAttribute('src')).toBe(false); + expect(video.load).toHaveBeenCalledTimes(1); + expect(table.scenegraph.updateNextFrame).toHaveBeenCalled(); + }); + + it('releases the video and redraws on load error', () => { + const { table, video } = createCell({ + videoFirstFrameSnapshot: true + }); + + video.dispatchEvent(new Event('error')); + + expect(video.pause).toHaveBeenCalledTimes(1); + expect(video.hasAttribute('src')).toBe(false); + expect(video.load).toHaveBeenCalledTimes(1); + expect(table.scenegraph.updateNextFrame).toHaveBeenCalled(); + }); + + it('keeps the video damage image aspect ratio immediately after load error', () => { + const { image, video } = createCell( + { + videoFirstFrameSnapshot: true + }, + { width: 360, height: 40 } + ); + const regedIcons = icons.get(); + const damageImage = regedIcons.video_damage_pic + ? (regedIcons.video_damage_pic as any).svg + : (regedIcons.damage_pic as any).svg; + + image.resources.set(damageImage, { + state: 'success', + data: { + width: 24, + height: 24 + } + }); + video.dispatchEvent(new Event('error')); + + expect(image.attribute.image).toBe(damageImage); + expect(image.attribute.x).toBe(0); + expect(image.attribute.y).toBe(0); + expect(image.attribute.width).toBe(40); + expect(image.attribute.height).toBe(40); + }); + + it('keeps custom video damage svg aspect ratio before the icon resource is cached', () => { + const originalVideoDamageIcon = (icons.icons as any).video_damage_pic; + (icons.icons as any).video_damage_pic = { + type: 'svg', + svg: '', + name: 'video_damage_pic', + positionType: 'right' + }; + + try { + const { image, video } = createCell( + { + videoFirstFrameSnapshot: true + }, + { width: 360, height: 40 } + ); + + video.dispatchEvent(new Event('error')); + + expect(image.attribute.image).toBe((icons.icons as any).video_damage_pic.svg); + expect(image.attribute.x).toBe(0); + expect(image.attribute.y).toBe(0); + expect(image.attribute.width).toBe(40); + expect(image.attribute.height).toBe(40); + } finally { + if (originalVideoDamageIcon) { + (icons.icons as any).video_damage_pic = originalVideoDamageIcon; + } else { + delete (icons.icons as any).video_damage_pic; + } + } + }); + + it('keeps the video damage image aspect ratio when the column resizes after load error', () => { + const { cellGroup, image, table, video } = createCell({ + videoFirstFrameSnapshot: true + }); + const regedIcons = icons.get(); + const damageImage = regedIcons.video_damage_pic + ? (regedIcons.video_damage_pic as any).svg + : (regedIcons.damage_pic as any).svg; + + video.dispatchEvent(new Event('error')); + image.resources.set(damageImage, { + state: 'success', + data: { + width: 24, + height: 24 + } + }); + cellGroup.setAttribute('width', 360); + updateImageCellContentWhileResize(cellGroup, 0, 0, 160, 0, table); + + expect(image.attribute.image).toBe(damageImage); + expect(image.attribute.x).toBe(0); + expect(image.attribute.y).toBe(0); + expect(image.attribute.width).toBe(120); + expect(image.attribute.height).toBe(120); + }); +}); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts index 8cffa12ad0..0402bf657b 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/image-cell.ts @@ -344,7 +344,7 @@ export function updateImageCellContentWhileResize( const leftIconWidth = (cellGroup as any)._cellLeftIconWidth ?? 0; const rightIconWidth = (cellGroup as any)._cellRightIconWidth ?? 0; - if ((image as any).keepAspectRatio) { + if ((image as any).keepAspectRatio || isDamagePic(image)) { const { width: imageWidth, height: imageHeight } = calcKeepAspectRatioSize( originImage.width || (originImage as any).videoWidth, originImage.height || (originImage as any).videoHeight, diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts index dea5246088..6fb63e5347 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/video-cell.ts @@ -23,6 +23,89 @@ import { dealWithIconLayout } from '../../utils/text-icon-layout'; const regedIcons = icons.get(); +function releaseVideoResource(video: HTMLVideoElement): void { + try { + video.pause(); + } catch (err) { + // ignore media cleanup errors + } + video.removeAttribute('src'); + try { + video.load(); + } catch (err) { + // ignore media cleanup errors + } +} + +function getVideoFirstFrameTimeout(table: BaseTableAPI): number { + const timeout = table.options.customConfig?.videoFirstFrameTimeout; + return typeof timeout === 'number' && timeout >= 0 ? timeout : 8000; +} + +function getVideoFirstFrameMaxCanvasSize(table: BaseTableAPI): number { + const maxCanvasSize = table.options.customConfig?.videoFirstFrameMaxCanvasSize; + return typeof maxCanvasSize === 'number' && maxCanvasSize > 0 ? maxCanvasSize : 512; +} + +function snapshotVideoFirstFrame(video: HTMLVideoElement, image: IImage, table: BaseTableAPI): boolean { + const displayWidth = image.attribute.width; + const displayHeight = image.attribute.height; + if ( + typeof displayWidth !== 'number' || + typeof displayHeight !== 'number' || + displayWidth <= 0 || + displayHeight <= 0 + ) { + return false; + } + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return false; + } + + const dpr = Math.min((typeof window === 'undefined' ? 1 : window.devicePixelRatio) || 1, 2); + const maxSize = getVideoFirstFrameMaxCanvasSize(table); + const scale = Math.min(dpr, maxSize / Math.max(displayWidth, displayHeight)); + canvas.width = Math.max(1, Math.ceil(displayWidth * scale)); + canvas.height = Math.max(1, Math.ceil(displayHeight * scale)); + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + + try { + context.drawImage(video, 0, 0, canvas.width, canvas.height); + image.setAttributes({ image: canvas as any }); + return true; + } catch (err) { + return false; + } +} + +function getSvgSize(svg: string): { width: number; height: number } | undefined { + const svgTag = svg.match(/]*>/i)?.[0]; + if (!svgTag) { + return undefined; + } + + const widthMatch = svgTag.match(/\bwidth=["']?([\d.]+)/i); + const heightMatch = svgTag.match(/\bheight=["']?([\d.]+)/i); + const width = widthMatch ? Number(widthMatch[1]) : undefined; + const height = heightMatch ? Number(heightMatch[1]) : undefined; + if (width > 0 && height > 0) { + return { width, height }; + } + + const viewBoxMatch = svgTag.match(/\bviewBox=["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i); + const viewBoxWidth = viewBoxMatch ? Number(viewBoxMatch[1]) : undefined; + const viewBoxHeight = viewBoxMatch ? Number(viewBoxMatch[2]) : undefined; + if (viewBoxWidth > 0 && viewBoxHeight > 0) { + return { width: viewBoxWidth, height: viewBoxHeight }; + } + + return undefined; +} + export function createVideoCellGroup( columnGroup: Group, xOrigin: number, @@ -162,7 +245,100 @@ export function createVideoCellGroup( // video const value = table.getCellValue(col, row); const video = document.createElement('video'); + video.muted = true; + video.playsInline = true; + const shouldSnapshot = table.options.customConfig?.videoFirstFrameSnapshot === true; + let loadTimer: ReturnType | undefined; + let videoReleased = false; + + const clearVideoLoadTimer = (): void => { + if (loadTimer !== undefined) { + clearTimeout(loadTimer); + loadTimer = undefined; + } + }; + const releaseCurrentVideo = (): void => { + if (videoReleased) { + return; + } + videoReleased = true; + clearVideoLoadTimer(); + releaseVideoResource(video); + }; + const isCurrentImage = (): boolean => cellGroup.getChildByName('image', true) === image; + const setVideoDamageImage = (): void => { + const regedIcons = icons.get(); + const damageIcon = regedIcons.video_damage_pic || regedIcons.damage_pic; + const damageImage = (damageIcon as any).svg; + image.setAttributes({ + image: damageImage + } as any); + const originImage = image.resources?.get(damageImage)?.data; + const svgSize = typeof damageImage === 'string' ? getSvgSize(damageImage) : undefined; + const originWidth = originImage?.width || (damageIcon as any).width || svgSize?.width || 24; + const originHeight = originImage?.height || (damageIcon as any).height || svgSize?.height || 24; + const { width: cellWidth, height: cellHeight, isMerge } = getCellRange(cellGroup, table); + const availableWidth = cellWidth - padding[1] - padding[3]; + const availableHeight = cellHeight - padding[0] - padding[2]; + + if (originWidth > 0 && originHeight > 0 && availableWidth > 0 && availableHeight > 0) { + const { width: imageWidth, height: imageHeight } = calcKeepAspectRatioSize( + originWidth, + originHeight, + availableWidth, + availableHeight + ); + const pos = calcStartPosition( + 0, + 0, + cellWidth, + cellHeight, + imageWidth, + imageHeight, + textAlign, + textBaseline, + padding + ); + + image.setAttributes({ + x: pos.x, + y: pos.y, + width: imageWidth, + height: imageHeight + }); + + if (isMerge) { + updateImageDxDy( + cellGroup.mergeStartCol, + cellGroup.mergeEndCol, + cellGroup.mergeStartRow, + cellGroup.mergeEndRow, + table + ); + } + } + }; + const handleVideoLoadFail = (): void => { + if (videoReleased) { + return; + } + if (isCurrentImage()) { + setVideoDamageImage(); + table.scenegraph.updateNextFrame(); + } + if (shouldSnapshot) { + releaseCurrentVideo(); + } + }; video.addEventListener('loadeddata', (): void => { + clearVideoLoadTimer(); + if (videoReleased) { + return; + } + if (!isCurrentImage()) { + releaseCurrentVideo(); + return; + } if (imageAutoSizing) { _adjustWidthHeight(col, row, video.videoWidth, video.videoHeight, table.scenegraph, padding, cellGroup); } @@ -248,17 +424,14 @@ export function createVideoCellGroup( }); playIcon.name = 'play-icon'; cellGroup.appendChild(playIcon); + if (shouldSnapshot && snapshotVideoFirstFrame(video, image, table)) { + releaseCurrentVideo(); + } // 触发重绘 table.scenegraph.updateNextFrame(); }); - video.onerror = (): void => { - const regedIcons = icons.get(); - (image as any).image = regedIcons.video_damage_pic - ? (regedIcons.video_damage_pic as any).svg - : (regedIcons.damage_pic as any).svg; - }; - video.src = value; - video.setAttribute('preload', 'auto'); + video.addEventListener('error', handleVideoLoadFail); + video.addEventListener('abort', handleVideoLoadFail); const image: IImage = createImage({ x: padding[3], @@ -291,6 +464,13 @@ export function createVideoCellGroup( table.scenegraph.updateNextFrame(); } }; + + video.setAttribute('preload', 'auto'); + video.src = value; + const timeout = getVideoFirstFrameTimeout(table); + if (shouldSnapshot && timeout > 0) { + loadTimer = setTimeout(handleVideoLoadFail, timeout); + } return cellGroup; } diff --git a/packages/vtable/src/ts-types/base-table.ts b/packages/vtable/src/ts-types/base-table.ts index 4c9811d85c..2a4e8740d1 100644 --- a/packages/vtable/src/ts-types/base-table.ts +++ b/packages/vtable/src/ts-types/base-table.ts @@ -639,6 +639,12 @@ export interface BaseTableConstructorOptions { // 图片资源请求时是否使用anonymous模式 imageAnonymous?: boolean; + // 视频单元格首帧绘制后是否替换为canvas快照并释放video资源 + videoFirstFrameSnapshot?: boolean; + // 视频单元格等待首帧的超时时间,单位ms,默认8000 + videoFirstFrameTimeout?: number; + // 视频单元格首帧快照canvas的最大边长,默认512 + videoFirstFrameMaxCanvasSize?: number; // 滚动到边界是否继续触发滚动事件 scrollEventAlwaysTrigger?: boolean;