@@ -53,23 +53,39 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
5353 }
5454
5555 $ result = null ;
56+
57+ // Timestamps to make attempts to generate a still
58+ $ timeAttempts = [5 , 1 , 0 ];
59+
60+ // By default, download $sizeAttempts from the file along with
61+ // the 'moov' atom.
62+ // Example bitrates in the higher range:
63+ // 4K HDR H265 60 FPS = 75 Mbps = 9 MB per second needed for a still
64+ // 1080p H265 30 FPS = 10 Mbps = 1.25 MB per second needed for a still
65+ // 1080p H264 30 FPS = 16 Mbps = 2 MB per second needed for a still
66+ $ sizeAttempts = [1024 * 1024 * 10 ];
67+
5668 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
6169 if ($ file ->getStorage ()->isLocal ()) {
62- $ sizeAttempts = [ 5242880 , null ];
63- } else {
64- $ sizeAttempts = [ 5242880 ] ;
70+ // Temp file required but file is local, so retrieve $sizeAttempt bytes first,
71+ // and if it doesn't work, retrieve the entire file.
72+ $ sizeAttempts[] = null ;
6573 }
6674 } else {
67- // size is irrelevant, only attempt once
75+ // Temp file is not required and file is local so retrieve entire file.
6876 $ sizeAttempts = [null ];
6977 }
7078
7179 foreach ($ sizeAttempts as $ size ) {
72- $ absPath = $ this ->getLocalFile ($ file , $ size );
80+ $ absPath = false ;
81+ // File is remote, generate a sparse file
82+ if (!$ file ->getStorage ()->isLocal ()) {
83+ $ absPath = $ this ->getSparseFile ($ file , $ size );
84+ }
85+ // Defaults to existing routine if generating sparse file fails
86+ if ($ absPath === false ) {
87+ $ absPath = $ this ->getLocalFile ($ file , $ size );
88+ }
7389 if ($ absPath === false ) {
7490 Server::get (LoggerInterface::class)->error (
7591 'Failed to get local file to generate thumbnail for: ' . $ file ->getPath (),
@@ -78,11 +94,11 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
7894 return null ;
7995 }
8096
81- $ result = $ this -> generateThumbNail ( $ maxX , $ maxY , $ absPath , 5 );
82- if ($ result === null ) {
83- $ result = $ this ->generateThumbNail ($ maxX , $ maxY , $ absPath , 1 );
84- if ($ result = == null ) {
85- $ result = $ this -> generateThumbNail ( $ maxX , $ maxY , $ absPath , 0 ) ;
97+ // Attempt still image grabs from selected timestamps
98+ foreach ($ timeAttempts as $ timeStamp ) {
99+ $ result = $ this ->generateThumbNail ($ maxX , $ maxY , $ absPath , $ timeStamp );
100+ if ($ result ! == null ) {
101+ break ;
86102 }
87103 }
88104
@@ -92,10 +108,111 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
92108 break ;
93109 }
94110 }
95-
96111 return $ result ;
97112 }
98113
114+ private function getSparseFile (File $ file , int $ size ): string |false {
115+ // File is smaller than $size or file is larger than max int size
116+ // of the host so return false so getLocalFile method is used
117+ if (($ size >= $ file ->getSize ()) || ($ file ->getSize () > PHP_INT_MAX )) {
118+ return false ;
119+ }
120+ $ content = $ file ->fopen ('r ' );
121+
122+ // Stream does not support seeking so generating a sparse file is not possible.
123+ if (stream_get_meta_data ($ content )['seekable ' ] !== true ) {
124+ fclose ($ content );
125+ return false ;
126+ }
127+
128+ $ absPath = Server::get (ITempManager::class)->getTemporaryFile ();
129+ if ($ absPath === false ) {
130+ Server::get (LoggerInterface::class)->error (
131+ 'Failed to get sparse file to generate thumbnail: ' . $ file ->getPath (),
132+ ['app ' => 'core ' ]
133+ );
134+ fclose ($ content );
135+ return false ;
136+ }
137+ $ sparseFile = fopen ($ absPath , 'w ' );
138+
139+ // Firsts 4 bytes indicate length of 1st atom.
140+ $ ftypSize = (int )hexdec (bin2hex (stream_get_contents ($ content , 4 , 0 )));
141+ // Download next 4 bytes to find name of 1st atom.
142+ $ ftypLabel = stream_get_contents ($ content , 4 , 4 );
143+
144+ // MP4/MOVs all begin with the 'ftyp' atom. Anything else is not MP4/MOV
145+ // and therefore should be processed differently.
146+ if ($ ftypLabel === 'ftyp ' ) {
147+ // Set offset for 2nd atom. Atoms begin where the previous one ends.
148+ $ offset = $ ftypSize ;
149+ $ moovSize = 0 ;
150+ $ moovOffset = 0 ;
151+ // Iterate and seek from atom to until the 'moov' atom is found or
152+ // EOF is reached
153+ while (($ offset + 8 < $ file ->getSize ()) && ($ moovSize === 0 )) {
154+ // First 4 bytes of atom header indicates size of the atom.
155+ $ atomSize = (int )hexdec (bin2hex (stream_get_contents ($ content , 4 , (int )$ offset )));
156+ // Next 4 bytes of atom header is the name/label of the atom
157+ $ atomLabel = stream_get_contents ($ content , 4 , (int )($ offset + 4 ));
158+ // Size value has two special values that don't directly indicate size
159+ // 0 = atom size equals the rest of the file
160+ if ($ atomSize === 0 ) {
161+ $ atomSize = $ file ->getsize () - $ offset ;
162+ } else {
163+ // 1 = read an additional 8 bytes after the label to get the 64 bit
164+ // size of the atom. Needed for large atoms like 'mdat' (the video data)
165+ if ($ atomSize === 1 ) {
166+ $ atomSize = (int )hexdec (bin2hex (stream_get_contents ($ content , 8 , (int )($ offset + 8 ))));
167+ }
168+ }
169+ // Found the 'moov' atom, store its location and size
170+ if ($ atomLabel === 'moov ' ) {
171+ $ moovSize = $ atomSize ;
172+ $ moovOffset = $ offset ;
173+ break ;
174+ }
175+ $ offset += $ atomSize ;
176+ }
177+ // 'moov' atom wasn't found or larger than $size
178+ // 'moov' atoms are generally small relative to video length.
179+ // Examples:
180+ // 4K HDR H265 60 FPS, 10 second video = 12.5 KB 'moov' atom, 54 MB total file size
181+ // 4K HDR H265 60 FPS, 5 minute video = 330 KB 'moov' atom, 1.95 GB total file size
182+ // Capping it at $size is a precaution against a corrupt/malicious 'moov' atom.
183+ // This effectively caps the total download size to 2x $size.
184+ // Also, if the 'moov' atom size+offset extends past EOF, it is invalid.
185+ if (($ moovSize === 0 ) || ($ moovSize > $ size ) || ($ moovOffset + $ moovSize > $ file ->getSize ())) {
186+ fclose ($ content );
187+ fclose ($ sparseFile );
188+ return false ;
189+ }
190+ // Generate new file of same size
191+ ftruncate ($ sparseFile , (int )($ file ->getSize ()));
192+ fseek ($ sparseFile , 0 );
193+ fseek ($ content , 0 );
194+ // Copy first $size bytes of video into new file
195+ stream_copy_to_stream ($ content , $ sparseFile , $ size , 0 );
196+
197+ // If 'moov' is located before $size in the video, it was already streamed,
198+ // so no need to download it again.
199+ if ($ moovOffset >= $ size ) {
200+ // Seek to where 'moov' atom needs to be placed
201+ fseek ($ content , (int )$ moovOffset );
202+ fseek ($ sparseFile , (int )$ moovOffset );
203+ stream_copy_to_stream ($ content , $ sparseFile , (int )$ moovSize , 0 );
204+ }
205+ } else {
206+ // 'ftyp' atom not found, not a valid MP4/MOV
207+ fclose ($ content );
208+ fclose ($ sparseFile );
209+ return false ;
210+ }
211+ fclose ($ content );
212+ fclose ($ sparseFile );
213+ return $ absPath ;
214+ }
215+
99216 private function useHdr (string $ absPath ): bool {
100217 // load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path
101218 $ ffprobe_binary = $ this ->config ->getSystemValue ('preview_ffprobe_path ' , null ) ?? (pathinfo ($ this ->binary , PATHINFO_DIRNAME ) . '/ffprobe ' );
@@ -124,7 +241,6 @@ private function useHdr(string $absPath): bool {
124241
125242 private function generateThumbNail (int $ maxX , int $ maxY , string $ absPath , int $ second ): ?IImage {
126243 $ tmpPath = Server::get (ITempManager::class)->getTemporaryFile ();
127-
128244 if ($ tmpPath === false ) {
129245 Server::get (LoggerInterface::class)->error (
130246 'Failed to get local file to generate thumbnail for: ' . $ absPath ,
0 commit comments