Skip to content

Commit c47bafb

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 c47bafb

File tree

1 file changed

+133
-13
lines changed

1 file changed

+133
-13
lines changed

lib/private/Preview/Movie.php

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

Comments
 (0)