1+ < div class ="container ">
2+ < div >
3+ < h3 >
4+ 先选择一个视频
5+ < input type ="file " accept ="video/* " id ="file ">
6+ </ h3 >
7+ </ div >
8+
9+ < h3 > 渲染全部视频帧</ h3 >
10+ < div id ="frame-container ">
11+ < div class ="play-line "> </ div >
12+ </ div >
13+ </ div >
14+
15+ < script >
16+ const onFileChange = ( callback ) => {
17+ if ( onFileChange . _callbacks ) onFileChange . _callbacks . push ( callback ) ;
18+ else ( onFileChange . _callbacks = [ callback ] ) ;
19+ } ;
20+ shadowDocument . querySelector ( '#file' ) . addEventListener ( 'change' , ( event ) => {
21+ const url = URL . createObjectURL ( event . target . files [ 0 ] ) ;
22+ onFileChange . _callbacks ?. forEach ( ( callback ) => callback ( url ) ) ;
23+ } ) ;
24+
25+ class VideoFrame {
26+ video = document . createElement ( 'video' ) ;
27+ canvas = document . createElement ( 'canvas' ) ;
28+ status = 'init' ;
29+ frameList = [ ] ;
30+
31+ // 渲染视频全部帧容器
32+ container = null ;
33+ // 每秒像素
34+ pxPerSecond = 0 ;
35+
36+ constructor ( {
37+ container,
38+ width,
39+ height,
40+ pxPerSecond,
41+ url,
42+ } ) {
43+ this . container = container ;
44+ const { width : containerWidth , height : containerHeight } = container . getBoundingClientRect ( ) ;
45+ this . canvas . width = width ?? containerWidth ;
46+ this . canvas . height = height ?? containerHeight ;
47+ pxPerSecond && ( this . pxPerSecond = pxPerSecond ) ;
48+
49+ container . appendChild ( this . canvas ) ;
50+
51+ this . onLoadedmetadata = this . onLoadedmetadata . bind ( this ) ;
52+ this . video . addEventListener ( 'loadedmetadata' , this . onLoadedmetadata ) ;
53+ url && this . load ( url ) ;
54+ }
55+
56+ async onLoadedmetadata ( ) {
57+ this . status = 'loaded' ;
58+ this . pxPerSecond && ( this . canvas . width = this . pxPerSecond * this . video . duration ) ;
59+ await this . getFrameData ( ) ;
60+ this . drawFrame ( ) ;
61+ }
62+
63+ async load ( { url, width, height, pxPerSecond } ) {
64+ this . status = 'loadeding' ;
65+ this . video . src = url ;
66+ this . video . load ( ) ;
67+
68+ if ( width ) {
69+ this . canvas . width = width ;
70+ }
71+ if ( height ) {
72+ this . canvas . height = height ;
73+ }
74+ if ( pxPerSecond ) {
75+ this . pxPerSecond = pxPerSecond ;
76+ }
77+
78+ await new Promise ( ( resolve , reject ) => {
79+ const _loadedmetadata = ( ) => {
80+ this . video . removeEventListener ( 'loadedmetadata' , _loadedmetadata ) ;
81+ resolve ( true ) ;
82+ } ;
83+ const _error = ( ) => {
84+ this . video . removeEventListener ( 'error' , _error ) ;
85+ reject ( false ) ;
86+ } ;
87+ this . video . addEventListener ( 'loadedmetadata' , _loadedmetadata ) ;
88+ this . video . addEventListener ( 'error' , _error ) ;
89+ } ) ;
90+ }
91+
92+ async render ( {
93+ width = this . canvas . width ,
94+ height = this . canvas . height ,
95+ pxPerSecond = this . pxPerSecond ,
96+ ...props
97+ } = { } ) {
98+ if ( pxPerSecond ) {
99+ width = pxPerSecond * this . video . duration ;
100+ }
101+ if ( props . canvas ) {
102+ props . canvas . width = width ;
103+ props . canvas . height = height ;
104+ } else {
105+ this . canvas . width = width ;
106+ }
107+ await this . getFrameData ( width ) ;
108+ this . drawFrame ( ) ;
109+ }
110+
111+ async getFrameData ( width = this . canvas . width ) {
112+ const videoWidth = this . video . videoWidth ;
113+ const videoHeight = this . video . videoHeight ;
114+ const duration = this . video . duration ;
115+ const canvasWidth = width ?? this . canvas . width ;
116+ const canvasHeight = this . canvas . height ;
117+
118+ const frameHeight = canvasHeight ;
119+ const frameWidth = videoWidth * frameHeight / videoHeight ;
120+
121+ // 计算时间轴需要多少帧
122+ const frameCount = Math . ceil ( canvasWidth / frameWidth ) ;
123+ // 计算帧间隔
124+ const interval = duration / frameCount ;
125+ this . frameList = [ ] ;
126+
127+ // 创建离屏canvas
128+ const offscreenCanvas = document . createElement ( 'canvas' ) ;
129+ const offscreenCtx = offscreenCanvas . getContext ( '2d' ) ;
130+ offscreenCanvas . width = frameWidth ;
131+ offscreenCanvas . height = frameHeight ;
132+
133+ for ( let i = 0 ; i < frameCount ; i ++ ) {
134+ try {
135+ // 跳转到指定时间点
136+ this . video . currentTime = i * interval ;
137+
138+ // 等待视频帧可用
139+ await new Promise ( ( resolve ) => {
140+ const onSeeked = ( ) => {
141+ this . video . removeEventListener ( 'seeked' , onSeeked ) ;
142+
143+ offscreenCtx . drawImage (
144+ this . video ,
145+ 0 ,
146+ 0 ,
147+ frameWidth ,
148+ frameHeight ,
149+ ) ;
150+ this . frameList . push ( {
151+ width : frameWidth ,
152+ height : frameHeight ,
153+ frame : offscreenCtx . getImageData ( 0 , 0 , frameWidth , frameHeight ) ,
154+ } ) ;
155+
156+ resolve ( true ) ;
157+ } ;
158+
159+ this . video . addEventListener ( 'seeked' , onSeeked ) ;
160+ } ) ;
161+ } catch ( error ) {
162+ console . error ( '生成帧预览时出错:' , error ) ;
163+ }
164+ }
165+ this . status = 'frameRendered' ;
166+ }
167+
168+ drawFrame ( {
169+ canvas = this . canvas ,
170+ } = { } ) {
171+ const ctx = canvas . getContext ( '2d' ) ;
172+ this . frameList . forEach ( ( { width, height, frame } , index ) => {
173+ ctx . putImageData ( frame , width * index , 0 ) ;
174+ } ) ;
175+ }
176+ }
177+
178+ // 因为是在shadow环境 所以这里使用了注入进来的 shadowDocument 去获取元素
179+ const frameContainer = shadowDocument . querySelector ( '#frame-container' ) ;
180+
181+ const videoFrame = new VideoFrame ( {
182+ container : frameContainer ,
183+ } ) ;
184+
185+ onFileChange ( ( url ) => {
186+ videoFrame . load ( { url } ) ;
187+ } ) ;
188+ </ script >
189+
190+ < style >
191+ .container {
192+ padding : 20px ;
193+ }
194+ # frame-container ,
195+ # frame-container-full {
196+ position : relative;
197+ height : 80px ;
198+ background-color : # 999 ;
199+ }
200+ </ style >
0 commit comments