@@ -54,22 +54,43 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
5454
5555		$ resultnull ;
5656		if  ($ this useTempFile ($ file
57- 			// Try downloading 5 MB first, as it's likely that the first frames are present there. 
58- 			// In some cases this doesn't work, for example when the moov atom is at the 
59- 			// end of the file, so if it fails we fall back to getting the full file. 
60- 			// Unless the file is not local (e.g. S3) as we do not want to download the whole (e.g. 37Gb) file 
57+ 			// Try downloading 10 MB first, as it's likely that the first needed frames are present 
58+ 			// there along with the 'moov' atom (used in MP4/MOV files). In some cases this doesn't 
59+ 			// work, (e.g. the 'moov' atom is at the end, or the videos is high bitrate) 
6160			if  ($ filegetStorage ()->isLocal ()) {
62- 				$ sizeAttempts5242880 , null ];
61+ 				// File is local, make two attempts: 10 MB, then the entire file 
62+ 				// Also, set attempts for timestamp at 5, 1, and 0 seconds 
63+ 				$ sizeAttempts10485760 , null ];
64+ 				$ timeAttempts5 , 1 , 0 ];
6365			} else  {
64- 				$ sizeAttempts5242880 ];
66+ 				// File is remote, make one attempt: 10 MB will be downloaded from the file along with 
67+ 				// the 'moov' atom. 
68+ 				// Also, set attempts for timestamp at 1 and 0 seconds only due to less video data. 
69+ 				// WARNING: setting the time attempts to higher values will generate corrupt previews 
70+ 				// especially on higher bitrate videos. 
71+ 				// Example bitrates in the higher range: 
72+ 				// 4K HDR H265 60 FPS = 75 Mbps = 9 MB per second needed for a still 
73+ 				// 1080p H265 30 FPS = 10 Mbps = 1.25 MB per second needed for a still 
74+ 				// 1080p H264 30 FPS = 16 Mbps = 2 MB per second needed for a still 
75+ 				$ sizeAttempts10485760 ];
76+ 				$ timeAttempts1 , 0 ];
6577			}
6678		} else  {
6779			// size is irrelevant, only attempt once 
6880			$ sizeAttemptsnull ];
81+ 			$ timeAttempts5 , 1 , 0 ];
6982		}
7083
7184		foreach  ($ sizeAttemptsas  $ size
72- 			$ absPath$ this getLocalFile ($ file$ size
85+ 			$ absPathfalse ;
86+ 			// File is remote, generate a sparse file 
87+ 			if  (!$ filegetStorage ()->isLocal ()) {
88+ 				$ absPath$ this getSparseFile ($ file$ size
89+ 			}
90+ 			// Defaults to existing routine if generating sparse file fails 
91+ 			if  ($ absPathfalse ) {
92+ 				$ absPath$ this getLocalFile ($ file$ size
93+ 			}
7394			if  ($ absPathfalse ) {
7495				Server::get (LoggerInterface::class)->error (
7596					'Failed to get local file to generate thumbnail for:  '  . $ filegetPath (),
@@ -78,14 +99,14 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
7899				return  null ;
79100			}
80101
81- 			$ result  =  $ this -> generateThumbNail ( $ maxX ,  $ maxY ,  $ absPath ,  5 ); 
82- 			if  ($ result  ===  null ) {
83- 				$ result$ this generateThumbNail ($ maxX$ maxY$ absPath1 );
84- 				if  ($ result= == null ) {
85- 					$ result  =  $ this -> generateThumbNail ( $ maxX ,  $ maxY ,  $ absPath ,  0 ) ;
102+ 			// Attempt still image grabs from selected timestamps 
103+ 			foreach  ($ timeAttempts   as   $ timeStamp 
104+ 				$ result$ this generateThumbNail ($ maxX$ maxY$ absPath$ timeStamp 
105+ 				if  ($ result! == null ) {
106+ 					break ;
86107				}
87108			}
88- 
109+ 			 
89110			$ this cleanTmpFiles ();
90111
91112			if  ($ resultnull ) {
@@ -95,6 +116,105 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
95116
96117		return  $ result
97118	}
119+ 	
120+ 	private  function  getSparseFile (File $ fileint  $ sizestring |false  {
121+ 		$ absPathget (ITempManager::class)->getTemporaryFile ();
122+ 		if  ($ absPathfalse ) {
123+ 			Server::get (LoggerInterface::class)->error (
124+ 				'Failed to get sparse file to generate thumbnail for:  '  . $ filegetPath (),
125+ 				['app '  => 'core ' ]
126+ 			);
127+ 			return  false ;
128+ 		}
129+ 		$ content$ filefopen ('r ' );
130+ 
131+ 		// Stream does not support seeking so generating a sparse file is not possible. 
132+ 		if  (stream_get_meta_data ($ content'seekable ' ] === false ) {
133+ 			fclose ($ content
134+ 			return  false ;
135+ 		}
136+ 
137+ 		$ sparseFilefopen ($ absPath'w ' );
138+ 		
139+ 		// If video size is less than or equal to $size then just download entire file 
140+ 		if  ($ size$ filegetSize ()) {
141+ 			stream_copy_to_stream ($ content$ sparseFile
142+ 		} else  {
143+ 			// Firsts 4 bytes indicate length of 1st atom. 
144+ 			$ ftypSizehexdec (bin2hex (stream_get_contents ($ content4 , 0 )));
145+ 			// Download next 4 bytes to find name of 1st atom. 
146+ 			$ ftypLabelstream_get_contents ($ content4 , 4 );
147+ 		
148+ 			// MP4/MOVs all begin with the 'ftyp' atom. Anything else is not MP4/MOV 
149+ 			// and therefore should be processed differently. 
150+ 			if  ($ ftypLabel'ftyp ' ) {
151+ 				// Set offset for 2nd atom. Atoms begin where the previous one ends. 
152+ 				$ offset$ ftypSize
153+ 				$ moovSize0 ;
154+ 				$ moovOffset0 ;
155+ 				// Iterate and seek from atom to until the 'moov' atom is found or 
156+ 				// EOF is reached 
157+ 				while  (($ offset8  < $ filegetSize ()) && ($ moovSize0 )) {
158+ 					// First 4 bytes of atom header indicates size of the atom. 
159+ 					$ atomSizehexdec (bin2hex (stream_get_contents ($ content4 , $ offset
160+ 					// Next 4 bytes of atom header is the name/label of the atom 
161+ 					$ atomLabelstream_get_contents ($ content4 , $ offset4 );
162+ 					// Size value has two special values that don't directly indicate size 
163+ 					// 0 = atom size equals the rest of the file 
164+ 					if  ($ atomSize0 ) {
165+ 						$ atomSize$ filegetsize () - $ offset
166+ 					} else  {
167+ 						// 1 = read an additional 8 bytes after the label to get the 64 bit 
168+ 						// size of the atom. Needed for large atoms like 'mdat' (the video data) 
169+ 						if  ($ atomSize1 ) {
170+ 							$ atomSizehexdec (bin2hex (stream_get_contents ($ content8 , $ offset8 )));
171+ 						}
172+ 					}
173+ 					// Found the 'moov' atom, store its location and size 
174+ 					if  ($ atomLabel'moov ' ) {
175+ 						$ moovSize$ atomSize
176+ 						$ moovOffset$ offset
177+ 						break ;
178+ 					}
179+ 					$ offset$ atomSize
180+ 				}
181+ 				// 'moov' atom wasn't found or larger than $size 
182+ 				// 'moov' atoms are generally small relative to video length. 
183+ 				// Examples: 
184+ 				// 4K HDR H265 60 FPS, 10 second video = 12.5 KB 'moov' atom, 54 MB total file size 
185+ 				// 4K HDR H265 60 FPS, 5 minute video = 330 KB 'moov' atom, 1.95 GB total file size 
186+ 				// Capping it at $size is a precaution against a corrupt/malicious 'moov' atom 
187+ 				// Also, if the 'moov' atom size+offset extends past EOF, it is invalid. 
188+ 				if  (($ moovSize0 ) || ($ moovSize$ size$ moovOffset$ moovSize$ filegetSize ())) {
189+ 					fclose ($ content
190+ 					fclose ($ sparseFile
191+ 					return  false ;
192+ 				}
193+ 				// Generate new file of same size 
194+ 				ftruncate ($ sparseFile$ filegetSize ());
195+ 				fseek ($ content0 );
196+ 				// Copy first $size bytes of video into new file 
197+ 				stream_copy_to_stream ($ content$ sparseFile$ size0 );
198+ 
199+ 				// If 'moov' is located after $size in the video, it was already streamed, 
200+ 				// so no need to download it again. 
201+ 				if  ($ moovOffset$ size
202+ 					// Seek to where 'moov' atom needs to be placed 
203+ 					fseek ($ content$ moovOffset
204+ 					fseek ($ sparseFile$ moovOffset
205+ 					stream_copy_to_stream ($ content$ sparseFile$ moovSize0 );
206+ 				}
207+ 			} else  {
208+ 				// 'ftyp' atom not found, not a valid MP4/MOV 
209+ 				fclose ($ content
210+ 				fclose ($ sparseFile
211+ 				return  false ;
212+ 			}
213+ 		}
214+ 		fclose ($ content
215+ 		fclose ($ sparseFile
216+ 		return  $ absPath
217+ 	}
98218
99219	private  function  useHdr (string  $ absPathbool  {
100220		// load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path 
0 commit comments