@@ -54,22 +54,42 @@ 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+ // 5 MB from the end with filler (null zeroes) in the middle.
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 ];
6981 }
7082
7183 foreach ($ sizeAttempts as $ size ) {
72- $ absPath = $ this ->getLocalFile ($ file , $ size );
84+ $ absPath = false ;
85+ // File is remote, generate a sparse file
86+ if (!$ file ->getStorage ()->isLocal ()) {
87+ $ absPath = $ this ->getSparseFile ($ file , $ size );
88+ }
89+ // Defaults to existing routine if generating sparse file fails
90+ if ($ absPath === false ) {
91+ $ absPath = $ this ->getLocalFile ($ file , $ size );
92+ }
7393 if ($ absPath === false ) {
7494 Server::get (LoggerInterface::class)->error (
7595 'Failed to get local file to generate thumbnail for: ' . $ file ->getPath (),
@@ -78,14 +98,14 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
7898 return null ;
7999 }
80100
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 ) ;
101+ // Attempt still image grabs from selected timestamps
102+ foreach ($ timeAttempts as $ timeStamp ) {
103+ $ result = $ this ->generateThumbNail ($ maxX , $ maxY , $ absPath , $ timeStamp );
104+ if ($ result ! == null ) {
105+ break ;
86106 }
87107 }
88-
108+
89109 $ this ->cleanTmpFiles ();
90110
91111 if ($ result !== null ) {
@@ -95,6 +115,101 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
95115
96116 return $ result ;
97117 }
118+
119+ private function getSparseFile (File $ file , int $ size ): string |false {
120+ $ absPath = Server::get (ITempManager::class)->getTemporaryFile ();
121+ if ($ absPath === false ) {
122+ Server::get (LoggerInterface::class)->error (
123+ 'Failed to get sparse file to generate thumbnail for: ' . $ file ->getPath (),
124+ ['app ' => 'core ' ]
125+ );
126+ return false ;
127+ }
128+ $ content = $ file ->fopen ('r ' );
129+
130+ // Stream does not support seeking so generating a sparse file is not possible.
131+ if (stream_get_meta_data ($ content )['seekable ' ] === false ) {
132+ fclose ($ content );
133+ return false ;
134+ }
135+
136+ $ sparseFile = fopen ($ absPath , 'w ' );
137+
138+ // If video size is less than or equal to $size then just download entire file
139+ if (($ size ) >= $ file ->getSize ()) {
140+ stream_copy_to_stream ($ content , $ sparseFile );
141+ } else {
142+ // Firsts 4 bytes indicate length of 1st atom.
143+ $ ftypSize = hexdec (bin2hex (stream_get_contents ($ content , 4 , 0 )));
144+ // Download next 4 bytes to find name of 1st atom.
145+ $ ftypLabel = stream_get_contents ($ content , 4 , 4 );
146+
147+ // MP4/MOVs all begin with the 'ftyp' atom. Anything else is not MP4/MOV
148+ // and therefore should be processed differently.
149+ if ($ ftypLabel === 'ftyp ' ) {
150+ // Set offset for 2nd atom. Atoms begin where the previous one ends.
151+ $ offset = $ ftypSize ;
152+ $ moovSize = 0 ;
153+ $ moovOffset = 0 ;
154+ // Iterate and seek from atom to until the 'moov' atom is found or
155+ // EOF is reached
156+ while (($ offset + 8 < $ file ->getSize ()) && ($ moovSize === 0 )) {
157+ // First 4 bytes of atom header indicates size of the atom.
158+ $ atomSize = hexdec (bin2hex (stream_get_contents ($ content , 4 , $ offset )));
159+ // Next 4 bytes of atom header is the name/label of the atom
160+ $ atomLabel = stream_get_contents ($ content , 4 , $ offset + 4 );
161+ // Size value has two special values that don't directly indicate size
162+ // 0 = atom size equals the rest of the file
163+ if ($ atomSize === 0 ) {
164+ $ atomSize = $ file ->getsize () - $ offset ;
165+ } else {
166+ // 1 = read an additional 8 bytes after the label to get the 64 bit
167+ // size of the atom. Needed for large atoms like 'mdat' (the video data)
168+ if ($ atomSize === 1 ) {
169+ $ atomSize = hexdec (bin2hex (stream_get_contents ($ content , 8 , $ offset + 8 )));
170+ }
171+ }
172+ // Found the 'moov' atom, store its location and size
173+ if ($ atomLabel === 'moov ' ) {
174+ $ moovSize = $ atomSize ;
175+ $ moovOffset = $ offset ;
176+ }
177+ $ offset += $ atomSize ;
178+ }
179+ // 'moov' atom wasn't found or larger than $size
180+ // 'moov' atoms are generally small relative to video length.
181+ // Examples:
182+ // 4K HDR H265 60 FPS, 10 second video = 12.5 KB 'moov' atom, 54 MB total file size
183+ // 4K HDR H265 60 FPS, 5 minute video = 330 KB 'moov' atom, 1.95 GB total file size
184+ // Capping it at $size is a precaution against a corrupt/malicious 'moov' atom
185+ // Also, if the 'moov' atom size+offset extends past EOF, it is invalid.
186+ if (($ moovSize === 0 ) || ($ moovSize > $ size ) || ($ moovOffset + $ moovSize > $ file ->getSize ())) {
187+ return false ;
188+ }
189+ // Generate new file of same size
190+ ftruncate ($ sparseFile , $ file ->getSize ());
191+ fseek ($ content , 0 );
192+ // Copy first $size bytes of video into new file
193+ stream_copy_to_stream ($ content , $ sparseFile , $ size , 0 );
194+
195+ // If 'moov' is located after $size in the video, it was already streamed,
196+ // so no need to download it again.
197+ if ($ moovOffset >= $ size ) {
198+ // Seek to where 'moov' atom needs to be placed
199+ fseek ($ content , $ moovOffset );
200+ fseek ($ sparseFile , $ moovOffset );
201+ stream_copy_to_stream ($ content , $ sparseFile , $ moovSize , 0 );
202+ }
203+ } else {
204+ // 'ftyp' atom not found, not a valid MP4/MOV
205+ fclose ($ content );
206+ return false ;
207+ }
208+ }
209+ fclose ($ content );
210+ fclose ($ sparseFile );
211+ return $ absPath ;
212+ }
98213
99214 private function useHdr (string $ absPath ): bool {
100215 // load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path
0 commit comments