@@ -54,22 +54,43 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
5454
5555 $ result = null ;
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 ($ file ->getStorage ()->isLocal ()) {
62- $ sizeAttempts = [5242880 , 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+ $ sizeAttempts = [10485760 , null ];
64+ $ timeAttempts = [5 , 1 , 0 ];
6365 } else {
64- $ sizeAttempts = [5242880 ];
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+ $ sizeAttempts = [10485760 ];
76+ $ timeAttempts = [1 , 0 ];
6577 }
6678 } else {
6779 // size is irrelevant, only attempt once
6880 $ sizeAttempts = [null ];
81+ $ timeAttempts = [5 , 1 , 0 ];
6982 }
7083
7184 foreach ($ sizeAttempts as $ size ) {
72- $ absPath = $ this ->getLocalFile ($ file , $ size );
85+ $ absPath = false ;
86+ // File is remote, generate a sparse file
87+ if (!$ file ->getStorage ()->isLocal ()) {
88+ $ absPath = $ this ->getSparseFile ($ file , $ size );
89+ }
90+ // Defaults to existing routine if generating sparse file fails
91+ if ($ absPath === false ) {
92+ $ absPath = $ this ->getLocalFile ($ file , $ size );
93+ }
7394 if ($ absPath === false ) {
7495 Server::get (LoggerInterface::class)->error (
7596 'Failed to get local file to generate thumbnail for: ' . $ file ->getPath (),
@@ -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 , $ absPath , 1 );
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 ($ result !== null ) {
@@ -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 $ file , int $ size ): string |false {
121+ $ absPath = Server::get (ITempManager::class)->getTemporaryFile ();
122+ if ($ absPath === false ) {
123+ Server::get (LoggerInterface::class)->error (
124+ 'Failed to get sparse file to generate thumbnail for: ' . $ file ->getPath (),
125+ ['app ' => 'core ' ]
126+ );
127+ return false ;
128+ }
129+ $ content = $ file ->fopen ('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+ $ sparseFile = fopen ($ absPath , 'w ' );
138+
139+ // If video size is less than or equal to $size then just download entire file
140+ if ($ size >= $ file ->getSize ()) {
141+ stream_copy_to_stream ($ content , $ sparseFile );
142+ } else {
143+ // Firsts 4 bytes indicate length of 1st atom.
144+ $ ftypSize = hexdec (bin2hex (stream_get_contents ($ content , 4 , 0 )));
145+ // Download next 4 bytes to find name of 1st atom.
146+ $ ftypLabel = stream_get_contents ($ content , 4 , 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+ $ moovSize = 0 ;
154+ $ moovOffset = 0 ;
155+ // Iterate and seek from atom to until the 'moov' atom is found or
156+ // EOF is reached
157+ while (($ offset + 8 < $ file ->getSize ()) && ($ moovSize === 0 )) {
158+ // First 4 bytes of atom header indicates size of the atom.
159+ $ atomSize = hexdec (bin2hex (stream_get_contents ($ content , 4 , $ offset )));
160+ // Next 4 bytes of atom header is the name/label of the atom
161+ $ atomLabel = stream_get_contents ($ content , 4 , $ offset + 4 );
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 ($ atomSize === 0 ) {
165+ $ atomSize = $ file ->getsize () - $ 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 ($ atomSize === 1 ) {
170+ $ atomSize = hexdec (bin2hex (stream_get_contents ($ content , 8 , $ offset + 8 )));
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 (($ moovSize === 0 ) || ($ moovSize > $ size ) || ($ moovOffset + $ moovSize > $ file ->getSize ())) {
189+ fclose ($ content );
190+ fclose ($ sparseFile );
191+ return false ;
192+ }
193+ // Generate new file of same size
194+ ftruncate ($ sparseFile , $ file ->getSize ());
195+ fseek ($ content , 0 );
196+ // Copy first $size bytes of video into new file
197+ stream_copy_to_stream ($ content , $ sparseFile , $ size , 0 );
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 , $ moovSize , 0 );
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 $ absPath ): bool {
100220 // load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path
0 commit comments