Skip to content

Commit 5c31caf

Browse files
feat(previews): previews for large remote files without full file download
Co-authored-by: Kate <26026535+provokateurin@users.noreply.github.com> Signed-off-by: invario <67800603+invario@users.noreply.github.com>
1 parent 8210e12 commit 5c31caf

File tree

1 file changed

+128
-13
lines changed

1 file changed

+128
-13
lines changed

lib/private/Preview/Movie.php

Lines changed: 128 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)