Skip to content

Commit a3ec763

Browse files
invariocome-nc
andcommitted
feat(previews): allow ffmpeg to connect direct for AWS S3 buckets
Co-authored-by: Côme Chilliet <[email protected]> Signed-off-by: invario <[email protected]>
1 parent 6720d7e commit a3ec763

File tree

2 files changed

+124
-59
lines changed

2 files changed

+124
-59
lines changed

apps/files_external/lib/Lib/Storage/AmazonS3.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,4 +757,35 @@ public function writeStream(string $path, $stream, ?int $size = null): int {
757757

758758
return $size;
759759
}
760+
761+
/**
762+
* Generates and returns a presigned URL that expires after set duration
763+
*
764+
*/
765+
public function getDirectDownload(string $path): array|false {
766+
$command = $this->getConnection()->getCommand('GetObject', [
767+
'Bucket' => $this->bucket,
768+
'Key' => $path,
769+
]);
770+
$duration = '+10 minutes';
771+
$expiration = new \DateTime();
772+
$expiration->modify($duration);
773+
774+
// generate a presigned URL that expires after $duration time
775+
$request = $this->getConnection()->createPresignedRequest($command, $duration, []);
776+
try {
777+
$presignedUrl = (string)$request->getUri();
778+
} catch (S3Exception $exception) {
779+
$this->logger->error($exception->getMessage(), [
780+
'app' => 'files_external',
781+
'exception' => $exception,
782+
]);
783+
}
784+
$result = [
785+
'url' => $presignedUrl,
786+
'presigned' => true,
787+
'expiration' => $expiration,
788+
];
789+
return $result;
790+
}
760791
}

lib/private/Preview/Movie.php

Lines changed: 93 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ public function isAvailable(FileInfo $file): bool {
4242
return is_string($this->binary);
4343
}
4444

45+
private function connectDirect(File $file): string|false {
46+
if (stream_get_meta_data($file->fopen('r'))['seekable'] !== true) {
47+
return false;
48+
}
49+
50+
// Checks for availability to access the video file directly via HTTP/HTTPS.
51+
// Returns a string containing URL if available. Only implemented and tested
52+
// with Amazon S3 currently. In all other cases, return false. ffmpeg
53+
// supports other protocols so this function may expand in the future.
54+
$gddValues = $file->getStorage()->getDirectDownload($file->getName());
55+
56+
if (is_array($gddValues)) {
57+
if (array_key_exists('url', $gddValues) && array_key_exists('presigned', $gddValues)) {
58+
$directUrl = (str_starts_with($gddValues['url'], 'http') && ($gddValues['presigned'] === true)) ? $gddValues['url'] : false;
59+
return $directUrl;
60+
}
61+
}
62+
return false;
63+
}
64+
4565
/**
4666
* {@inheritDoc}
4767
*/
@@ -54,74 +74,87 @@ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
5474

5575
$result = null;
5676

77+
$connectDirect = $this->connectDirect($file);
78+
5779
// Timestamps to make attempts to generate a still
5880
$timeAttempts = [5, 1, 0];
5981

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-
68-
if ($this->useTempFile($file)) {
69-
if ($file->getStorage()->isLocal()) {
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;
82+
// If HTTP/HTTPS direct connect is not available or if the file is encrypted,
83+
// process normally
84+
if (($connectDirect === false) || $file->isEncrypted()) {
85+
// By default, download $sizeAttempts from the file along with
86+
// the 'moov' atom.
87+
// Example bitrates in the higher range:
88+
// 4K HDR H265 60 FPS = 75 Mbps = 9 MB per second needed for a still
89+
// 1080p H265 30 FPS = 10 Mbps = 1.25 MB per second needed for a still
90+
// 1080p H264 30 FPS = 16 Mbps = 2 MB per second needed for a still
91+
$sizeAttempts = [1024 * 1024 * 10];
92+
93+
if ($this->useTempFile($file)) {
94+
if ($file->getStorage()->isLocal()) {
95+
// Temp file required but file is local, so retrieve $sizeAttempt bytes first,
96+
// and if it doesn't work, retrieve the entire file.
97+
$sizeAttempts[] = null;
98+
}
99+
} else {
100+
// Temp file is not required and file is local so retrieve entire file.
101+
$sizeAttempts = [null];
73102
}
74-
} else {
75-
// Temp file is not required and file is local so retrieve entire file.
76-
$sizeAttempts = [null];
77-
}
78103

79-
foreach ($sizeAttempts as $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-
}
89-
if ($absPath === false) {
90-
Server::get(LoggerInterface::class)->error(
91-
'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
92-
['app' => 'core']
93-
);
94-
return null;
95-
}
104+
foreach ($sizeAttempts as $size) {
105+
$absPath = false;
106+
// File is remote, generate a sparse file
107+
if (!$file->getStorage()->isLocal()) {
108+
$absPath = $this->getSparseFile($file, $size);
109+
}
110+
// Defaults to existing routine if generating sparse file fails
111+
if ($absPath === false) {
112+
$absPath = $this->getLocalFile($file, $size);
113+
}
114+
if ($absPath === false) {
115+
Server::get(LoggerInterface::class)->error(
116+
'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
117+
['app' => 'core']
118+
);
119+
return null;
120+
}
121+
122+
// Attempt still image grabs from selected timestamps
123+
foreach ($timeAttempts as $timeStamp) {
124+
$result = $this->generateThumbNail($maxX, $maxY, $absPath, $timeStamp);
125+
if ($result !== null) {
126+
break;
127+
}
128+
Server::get(LoggerInterface::class)->debug(
129+
'Movie preview generation attempt failed'
130+
. ', file=' . $file->getPath()
131+
. ', time=' . $timeStamp
132+
. ', size=' . ($size ?? 'entire file'),
133+
['app' => 'core']
134+
);
135+
}
136+
137+
$this->cleanTmpFiles();
96138

97-
// Attempt still image grabs from selected timestamps
98-
foreach ($timeAttempts as $timeStamp) {
99-
$result = $this->generateThumbNail($maxX, $maxY, $absPath, $timeStamp);
100139
if ($result !== null) {
140+
Server::get(LoggerInterface::class)->debug(
141+
'Movie preview generation attempt success'
142+
. ', file=' . $file->getPath()
143+
. ', time=' . $timeStamp
144+
. ', size=' . ($size ?? 'entire file'),
145+
['app' => 'core']
146+
);
101147
break;
102148
}
103-
Server::get(LoggerInterface::class)->debug(
104-
'Movie preview generation attempt failed'
105-
. ', file=' . $file->getPath()
106-
. ', time=' . $timeStamp
107-
. ', size=' . ($size ?? 'entire file'),
108-
['app' => 'core']
109-
);
110149
}
111-
112-
$this->cleanTmpFiles();
113-
114-
if ($result !== null) {
115-
Server::get(LoggerInterface::class)->debug(
116-
'Movie preview generation attempt success'
117-
. ', file=' . $file->getPath()
118-
. ', time=' . $timeStamp
119-
. ', size=' . ($size ?? 'entire file'),
120-
['app' => 'core']
121-
);
122-
break;
150+
} else {
151+
// HTTP/HTTPS direct connect is available so pass the URL directly to ffmpeg
152+
foreach ($timeAttempts as $timeStamp) {
153+
$result = $this->generateThumbNail($maxX, $maxY, $connectDirect, $timeStamp);
154+
if ($result !== null) {
155+
break;
156+
}
123157
}
124-
125158
}
126159
if ($result === null) {
127160
Server::get(LoggerInterface::class)->error(
@@ -245,7 +278,8 @@ private function getSparseFile(File $file, int $size): string|false {
245278

246279
private function useHdr(string $absPath): bool {
247280
// load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path
248-
$ffprobe_binary = $this->config->getSystemValue('preview_ffprobe_path', null) ?? (pathinfo($this->binary, PATHINFO_DIRNAME) . '/ffprobe');
281+
$ffprobe_binary = $this->config->getSystemValue('preview_ffprobe_path', null)
282+
?? (pathinfo($this->binary, PATHINFO_DIRNAME) . '/ffprobe');
249283
// run ffprobe on the video file to get value of "color_transfer"
250284
$test_hdr_cmd = [$ffprobe_binary,'-select_streams', 'v:0',
251285
'-show_entries', 'stream=color_transfer',
@@ -326,11 +360,11 @@ private function generateThumbNail(int $maxX, int $maxY, string $absPath, int $s
326360
if ($image->valid()) {
327361
unlink($tmpPath);
328362
$image->scaleDownToFit($maxX, $maxY);
363+
329364
return $image;
330365
}
331366
}
332367

333-
334368
unlink($tmpPath);
335369
return null;
336370
}

0 commit comments

Comments
 (0)