mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
timeline
This commit is contained in:
+1
-1
@@ -29,7 +29,7 @@ ENV VITE_APPWRITE_GROWTH_ENDPOINT=$VITE_APPWRITE_GROWTH_ENDPOINT
|
||||
RUN npm ci
|
||||
RUN npm run build
|
||||
|
||||
FROM appwrite/base:0.2.0 as final
|
||||
FROM appwrite/base:0.1.0 as final
|
||||
|
||||
LABEL maintainer="team@appwrite.io"
|
||||
|
||||
|
||||
+67
-18
@@ -3576,24 +3576,13 @@ $collections = [
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => 'timeline',
|
||||
'type' => Database::VAR_BOOLEAN,
|
||||
'$id' => 'previewId',
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 0,
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => false,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => 'preview',
|
||||
'type' => Database::VAR_BOOLEAN,
|
||||
'format' => '',
|
||||
'size' => 0,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
@@ -3621,10 +3610,10 @@ $collections = [
|
||||
],
|
||||
[
|
||||
'$id' => 'duration',
|
||||
'type' => Database::VAR_STRING,
|
||||
'type' => Database::VAR_INTEGER,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'size' => 0,
|
||||
'signed' => false,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
@@ -3762,6 +3751,66 @@ $collections = [
|
||||
],
|
||||
]
|
||||
],
|
||||
'videos_previews' => [
|
||||
'$collection' => Database::METADATA,
|
||||
'$id' => 'videos_previews',
|
||||
'$name' => 'Videos_previews',
|
||||
'attributes' => [
|
||||
[
|
||||
'$id' => 'videoId',
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => 'type',
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => true,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => 'name',
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => 'path',
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => Database::LENGTH_KEY,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
'indexes' => [
|
||||
[
|
||||
'$id' => '_key_video_type',
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['videoId', 'type'],
|
||||
'lengths' => [],
|
||||
'orders' => [],
|
||||
],
|
||||
]
|
||||
],
|
||||
'videos_renditions' => [
|
||||
'$collection' => Database::METADATA,
|
||||
'$id' => 'videos_renditions',
|
||||
|
||||
@@ -579,11 +579,21 @@ return [
|
||||
'description' => 'Video subtitle not valid.',
|
||||
'code' => 400,
|
||||
],
|
||||
Exception::VIDEO_SECOND_OUT_OF_RANGE => [
|
||||
'name' => Exception::VIDEO_SECOND_OUT_OF_RANGE,
|
||||
'description' => 'Video second out of duration range.',
|
||||
'code' => 400,
|
||||
],
|
||||
Exception::VIDEO_NOT_FOUND => [
|
||||
'name' => Exception::VIDEO_NOT_FOUND,
|
||||
'description' => 'Video not found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::VIDEO_PREVIEW_NOT_FOUND => [
|
||||
'name' => Exception::VIDEO_PREVIEW_NOT_FOUND,
|
||||
'description' => 'Video preview not found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::VIDEO_TIMELINE_NOT_FOUND => [
|
||||
'name' => Exception::VIDEO_TIMELINE_NOT_FOUND,
|
||||
'description' => 'Video timeline not found.',
|
||||
|
||||
+79
-123
@@ -19,8 +19,10 @@ use Utopia\Image\Image;
|
||||
use Utopia\Storage\Compression\Algorithms\GZIP;
|
||||
use Utopia\Storage\Compression\Algorithms\Zstd;
|
||||
use Utopia\Storage\Device;
|
||||
use Utopia\Validator;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\HexColor;
|
||||
use Utopia\Validator\Numeric;
|
||||
use Utopia\Validator\Range;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
@@ -195,6 +197,7 @@ App::put('/v1/videos/:videoId')
|
||||
'bucketId' => $bucketId,
|
||||
'fileId' => $file->getId(),
|
||||
'size' => $file->getAttribute('sizeOriginal'),
|
||||
'previewId' => null,
|
||||
'duration' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
@@ -286,8 +289,8 @@ App::get('/v1/videos')
|
||||
]), Response::MODEL_VIDEO_LIST);
|
||||
});
|
||||
|
||||
App::get('/v1/videos/timeline/:videoId')
|
||||
->desc('Get video timeline ')
|
||||
App::get('/v1/videos/:videoId/timeline')
|
||||
->desc('Get video timeline')
|
||||
->groups(['api', 'videos'])
|
||||
->label('scope', 'videos.read')
|
||||
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
|
||||
@@ -295,8 +298,8 @@ App::get('/v1/videos/timeline/:videoId')
|
||||
->label('sdk.method', 'getTimeline')
|
||||
->label('sdk.description', '/docs/references/videos/get-video-timeline.md') // TODO: Create markdown
|
||||
->label('sdk.response.code', Response::STATUS_CODE_OK)
|
||||
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
|
||||
->label('sdk.response.model', Response::MODEL_VIDEO)
|
||||
->label('sdk.response.type', '*/*')
|
||||
->label('sdk.methodType', 'location')
|
||||
->param('videoId', '', new UID(), 'Video unique ID.')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
@@ -312,7 +315,9 @@ App::get('/v1/videos/timeline/:videoId')
|
||||
|
||||
validateFilePermissions($dbForProject, $video['bucketId'], $video['fileId'], $mode);
|
||||
|
||||
if (!$video->getAttribute('timeline')) {
|
||||
$timeline = Authorization::skip(fn () => $dbForProject->find('videos_previews', [Query::equal('type', ['sprite'])]));
|
||||
|
||||
if (empty($timeline)) {
|
||||
throw new Exception(Exception::VIDEO_TIMELINE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@@ -321,13 +326,58 @@ App::get('/v1/videos/timeline/:videoId')
|
||||
->send($data);
|
||||
});
|
||||
|
||||
App::get('/v1/videos/:videoId/preview')
|
||||
App::post('/v1/videos/:videoId/preview')
|
||||
->desc('Create Video preview from a given point in duration (second)')
|
||||
->groups(['api', 'videos'])
|
||||
->label('scope', 'videos.write')
|
||||
->label('audits.event', 'video.create')
|
||||
->label('audits.resource', 'video/{response.$id}')
|
||||
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
|
||||
->label('sdk.namespace', 'videos')
|
||||
->label('sdk.method', 'createPreview')
|
||||
->label('sdk.description', '/docs/references/videos/create.md') // TODO: Create markdown
|
||||
->label('sdk.response.code', Response::STATUS_CODE_OK)
|
||||
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
|
||||
->label('sdk.response.model', Response::MODEL_VIDEO)
|
||||
->param('videoId', '', new UID(), 'Video unique ID.')
|
||||
->param('second', 5, new Numeric(), 'Second from video duration.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('mode')
|
||||
->action(action: function (string $videoId, int $second, Request $request, Response $response, Document $project, Database $dbForProject, string $mode) {
|
||||
|
||||
$video = Authorization::skip(fn() => $dbForProject->getDocument('videos', $videoId));
|
||||
if ($video->isEmpty()) {
|
||||
throw new Exception(Exception::VIDEO_NOT_FOUND);
|
||||
}
|
||||
|
||||
validateFilePermissions($dbForProject, $video['bucketId'], $video['fileId'], $mode);
|
||||
|
||||
$range = new Range(1, ($video['duration'] / 1000), Validator::TYPE_INTEGER);
|
||||
if (!$range->isValid($second)) {
|
||||
throw new Exception(Exception::VIDEO_SECOND_OUT_OF_RANGE);
|
||||
}
|
||||
|
||||
(new Transcoding())
|
||||
->setAction('preview')
|
||||
->setProject($project)
|
||||
->setVideo($video)
|
||||
->setSecond($second)
|
||||
->trigger();
|
||||
|
||||
$response->setStatusCode(Response::STATUS_CODE_CREATED);
|
||||
$response->dynamic($video, Response::MODEL_VIDEO);
|
||||
});
|
||||
|
||||
App::get('/v1/videos/:videoId/preview/:previewId')
|
||||
->desc('Get video file preview')
|
||||
->groups(['api', 'videos'])
|
||||
->label('scope', 'videos.read')
|
||||
->label('cache', true)
|
||||
->label('cache.resourceType', 'bucket/{request.bucketId}')
|
||||
->label('cache.resource', 'video/{request.videoId}')
|
||||
->label('cache.resourceType', 'video/{request.videoId}')
|
||||
->label('cache.resource', 'preview/{request.previewId}')
|
||||
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT])
|
||||
->label('sdk.namespace', 'videos')
|
||||
->label('sdk.method', 'getVideoImagePreview')
|
||||
@@ -335,144 +385,50 @@ App::get('/v1/videos/:videoId/preview')
|
||||
->label('sdk.response.code', Response::STATUS_CODE_OK)
|
||||
->label('sdk.response.type', Response::CONTENT_TYPE_IMAGE)
|
||||
->label('sdk.methodType', 'location')
|
||||
->param('videoId', '', new UID(), 'Video unique ID.') ->param('fileId', '', new UID(), 'File ID')
|
||||
->param('videoId', '', new UID(), 'Video unique ID.')
|
||||
->param('previewId', '', new UID(), 'Preview unique ID.')
|
||||
->param('width', 0, new Range(0, 4000), 'Resize preview image width, Pass an integer between 0 to 4000.', true)
|
||||
->param('height', 0, new Range(0, 4000), 'Resize preview image height, Pass an integer between 0 to 4000.', true)
|
||||
->param('gravity', Image::GRAVITY_CENTER, new WhiteList(Image::getGravityTypes()), 'Image crop gravity. Can be one of ' . implode(",", Image::getGravityTypes()), true)
|
||||
->param('quality', 100, new Range(0, 100), 'Preview image quality. Pass an integer between 0 to 100. Defaults to 100.', true)
|
||||
->param('borderWidth', 0, new Range(0, 100), 'Preview image border in pixels. Pass an integer between 0 to 100. Defaults to 0.', true)
|
||||
->param('borderColor', '', new HexColor(), 'Preview image border color. Use a valid HEX color, no # is needed for prefix.', true)
|
||||
->param('borderRadius', 0, new Range(0, 4000), 'Preview image border radius in pixels. Pass an integer between 0 to 4000.', true)
|
||||
->param('opacity', 1, new Range(0, 1, Range::TYPE_FLOAT), 'Preview image opacity. Only works with images having an alpha channel (like png). Pass a number between 0 to 1.', true)
|
||||
->param('rotation', 0, new Range(-360, 360), 'Preview image rotation in degrees. Pass an integer between -360 and 360.', true)
|
||||
->param('background', '', new HexColor(), 'Preview image background color. Only works with transparent images (png). Use a valid HEX color, no # is needed for prefix.', true)
|
||||
->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true)
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('mode')
|
||||
->inject('deviceFiles')
|
||||
->inject('deviceLocal')
|
||||
->action(function (string $bucketId, string $fileId, int $width, int $height, string $gravity, int $quality, int $borderWidth, string $borderColor, int $borderRadius, float $opacity, int $rotation, string $background, string $output, Request $request, Response $response, Document $project, Database $dbForProject, string $mode, Device $deviceFiles, Device $deviceLocal) {
|
||||
->inject('deviceVideos')
|
||||
->action(function (string $videoId, string $previewId, int $width, int $height, Request $request, Response $response, Document $project, Database $dbForProject, string $mode, Device $deviceVideos) {
|
||||
|
||||
if (!\extension_loaded('imagick')) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing');
|
||||
}
|
||||
|
||||
$bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
|
||||
$video = Authorization::skip(fn() => $dbForProject->getDocument('videos', $videoId));
|
||||
|
||||
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && $mode !== APP_MODE_ADMIN)) {
|
||||
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
|
||||
if ($video->isEmpty()) {
|
||||
throw new Exception(Exception::VIDEO_NOT_FOUND);
|
||||
}
|
||||
|
||||
$fileSecurity = $bucket->getAttribute('fileSecurity', false);
|
||||
$validator = new Authorization(Database::PERMISSION_READ);
|
||||
$valid = $validator->isValid($bucket->getRead());
|
||||
if (!$fileSecurity && !$valid) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED);
|
||||
validateFilePermissions($dbForProject, $video['bucketId'], $video['fileId'], $mode);
|
||||
|
||||
$preview = Authorization::skip(fn() => $dbForProject->getDocument('videos_previews', $previewId));
|
||||
|
||||
if ($preview->isEmpty()) {
|
||||
throw new Exception(Exception::VIDEO_PREVIEW_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((\strpos($request->getAccept(), 'image/webp') === false) && ('webp' === $output)) { // Fallback webp to jpeg when no browser support
|
||||
$output = 'jpg';
|
||||
}
|
||||
|
||||
$inputs = Config::getParam('storage-inputs');
|
||||
$output = 'jpg';
|
||||
$contentType = $mime = 'image/jpeg';
|
||||
$outputs = Config::getParam('storage-outputs');
|
||||
$fileLogos = Config::getParam('storage-logos');
|
||||
|
||||
if ($fileSecurity && !$valid) {
|
||||
$file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId);
|
||||
} else {
|
||||
$file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId));
|
||||
}
|
||||
|
||||
if ($file->isEmpty()) {
|
||||
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
$path = $file->getAttribute('path');
|
||||
$type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION));
|
||||
$algorithm = $file->getAttribute('algorithm', 'none');
|
||||
$cipher = $file->getAttribute('openSSLCipher');
|
||||
$mime = $file->getAttribute('mimeType');
|
||||
if (!\in_array($mime, $inputs) || $file->getAttribute('sizeActual') > (int) App::getEnv('_APP_STORAGE_PREVIEW_LIMIT', 20000000)) {
|
||||
if (!\in_array($mime, $inputs)) {
|
||||
$path = (\array_key_exists($mime, $fileLogos)) ? $fileLogos[$mime] : $fileLogos['default'];
|
||||
} else {
|
||||
// it was an image but the file size exceeded the limit
|
||||
$path = $fileLogos['default_image'];
|
||||
}
|
||||
|
||||
$algorithm = 'none';
|
||||
$cipher = null;
|
||||
$background = (empty($background)) ? 'eceff1' : $background;
|
||||
$type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION));
|
||||
$deviceFiles = $deviceLocal;
|
||||
}
|
||||
|
||||
if (!$deviceFiles->exists($path)) {
|
||||
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
|
||||
}
|
||||
$path = $preview->getAttribute('path') . $preview->getAttribute('name');
|
||||
$type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if (empty($output)) {
|
||||
// when file extension is not provided and the mime type is not one of our supported outputs
|
||||
// we fallback to `jpg` output format
|
||||
$output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type;
|
||||
}
|
||||
|
||||
|
||||
$source = $deviceFiles->read($path);
|
||||
|
||||
if (!empty($cipher)) { // Decrypt
|
||||
$source = OpenSSL::decrypt(
|
||||
$source,
|
||||
$file->getAttribute('openSSLCipher'),
|
||||
App::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')),
|
||||
0,
|
||||
\hex2bin($file->getAttribute('openSSLIV')),
|
||||
\hex2bin($file->getAttribute('openSSLTag'))
|
||||
);
|
||||
}
|
||||
|
||||
switch ($algorithm) {
|
||||
case 'zstd':
|
||||
$compressor = new Zstd();
|
||||
$source = $compressor->decompress($source);
|
||||
break;
|
||||
case 'gzip':
|
||||
$compressor = new GZIP();
|
||||
$source = $compressor->decompress($source);
|
||||
break;
|
||||
}
|
||||
|
||||
$image = new Image($source);
|
||||
|
||||
$image->crop((int) $width, (int) $height, $gravity);
|
||||
|
||||
if (!empty($opacity) || $opacity === 0) {
|
||||
$image->setOpacity($opacity);
|
||||
}
|
||||
|
||||
if (!empty($background)) {
|
||||
$image->setBackground('#' . $background);
|
||||
}
|
||||
|
||||
if (!empty($borderWidth)) {
|
||||
$image->setBorder($borderWidth, '#' . $borderColor);
|
||||
}
|
||||
|
||||
if (!empty($borderRadius)) {
|
||||
$image->setBorderRadius($borderRadius);
|
||||
}
|
||||
|
||||
if (!empty($rotation)) {
|
||||
$image->setRotation(($rotation + 360) % 360);
|
||||
}
|
||||
|
||||
$data = $image->output($output, $quality);
|
||||
|
||||
$contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg'];
|
||||
$source = $deviceVideos->read($path);
|
||||
$image = new Image($source);
|
||||
$image->crop((int)$width, (int)$height, Image::GRAVITY_CENTER);
|
||||
$data = $image->output($output, 100);
|
||||
|
||||
$response
|
||||
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + 60 * 60 * 24 * 30) . ' GMT')
|
||||
|
||||
@@ -217,7 +217,6 @@ App::init()
|
||||
|
||||
if ($type === 'bucket') {
|
||||
$bucketId = $parts[1] ?? null;
|
||||
|
||||
$bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
|
||||
|
||||
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && $mode !== APP_MODE_ADMIN)) {
|
||||
@@ -244,14 +243,13 @@ App::init()
|
||||
throw new Exception(Exception::STORAGE_FILE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
var_dump('hit');
|
||||
$response
|
||||
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $timestamp) . ' GMT')
|
||||
->addHeader('X-Appwrite-Cache', 'hit')
|
||||
->setContentType($data['contentType'])
|
||||
->send(base64_decode($data['payload']))
|
||||
;
|
||||
|
||||
$route->setIsActive(false);
|
||||
} else {
|
||||
$response->addHeader('X-Appwrite-Cache', 'miss');
|
||||
@@ -501,6 +499,7 @@ App::shutdown()
|
||||
$cacheLog = $dbForProject->getDocument('cache', $key);
|
||||
$accessedAt = $cacheLog->getAttribute('accessedAt', '');
|
||||
$now = DateTime::now();
|
||||
|
||||
if ($cacheLog->isEmpty()) {
|
||||
Authorization::skip(fn () => $dbForProject->createDocument('cache', new Document([
|
||||
'$id' => $key,
|
||||
|
||||
+1
-1
@@ -889,7 +889,7 @@ App::setResource('project', function ($dbForConsole, $request, $console) {
|
||||
/** @var Utopia\Database\Document $console */
|
||||
|
||||
$projectId = $request->getParam('project', $request->getHeader('x-appwrite-project', 'console'));
|
||||
$projectId = '6425c03a3ce2f21b2d1d';
|
||||
//$projectId = '6425c03a3ce2f21b2d1d';
|
||||
|
||||
if ($projectId === 'console') {
|
||||
return $console;
|
||||
|
||||
+85
-43
@@ -128,9 +128,7 @@ class TranscodingV1 extends Worker
|
||||
console::error('Not an valid Video file "' . $inPath . '"');
|
||||
}
|
||||
|
||||
$media = $this->ffmpeg->open($inPath);
|
||||
|
||||
if ($this->action === 'timeline') {
|
||||
if (empty($this->video->getAttribute('duration'))) {
|
||||
/**
|
||||
* Original asset metadata
|
||||
*/
|
||||
@@ -138,7 +136,7 @@ class TranscodingV1 extends Worker
|
||||
$mediaInfoContainer = $mediaInfo->getInfo($inPath);
|
||||
$general = $mediaInfoContainer->getGeneral();
|
||||
$this->video
|
||||
->setAttribute('duration', $general->has('duration') ? strval($general->get('duration')->getMilliseconds()) : '')
|
||||
->setAttribute('duration', $general->has('duration') ? $general->get('duration')->getMilliseconds() : 0)
|
||||
->setAttribute('format', $general->has('format') ? $general->get('format')->getShortName() : '');
|
||||
|
||||
foreach ($mediaInfoContainer->getVideos() ?? [] as $video) {
|
||||
@@ -163,14 +161,65 @@ class TranscodingV1 extends Worker
|
||||
console::info('Input video id: ' . $this->video->getId() . PHP_EOL .
|
||||
'Input name: ' . $this->file->getAttribute('name') . PHP_EOL .
|
||||
'Input width: ' . $this->video->getAttribute('width') . ' px' . PHP_EOL .
|
||||
'Input height: ' . $this->video->getAttribute('height') . ' px' . PHP_EOL .
|
||||
'Input duration: ' . ($this->video->getAttribute('duration') / 1000) . ' sec' . PHP_EOL .
|
||||
'Input height: ' . $this->video->getAttribute('height') . ' px' . PHP_EOL .
|
||||
'Input duration: ' . ($this->video->getAttribute('duration') / 1000) . ' Sec' . PHP_EOL .
|
||||
'Input size: ' . ($this->video->getAttribute('size') / 1024 / 1024) . ' MiB' . PHP_EOL .
|
||||
'Input videoBitRate: ' . ($this->video->getAttribute('videoBitRate') / 1000) . ' kb/s' . PHP_EOL .
|
||||
'Input audioBitRate: ' . ($this->video->getAttribute('audioBitRate') / 1000) . ' kb/s' . PHP_EOL);
|
||||
|
||||
$this->database->updateDocument('videos', $this->video->getId(), $this->video);
|
||||
$this->database->updateDocument(
|
||||
'videos',
|
||||
$this->video->getId(),
|
||||
new document(
|
||||
array_filter((array)$this->video, fn($value) => !is_null($value))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$media = $this->ffmpeg->open($inPath);
|
||||
|
||||
if ($this->action === 'preview') {
|
||||
console::info('Creating preview image from second ' . $this->args['second']);
|
||||
|
||||
$path = $this->getVideoDevice($this->project->getId())->getPath($this->video->getId()) . '/preview/';
|
||||
$name = 'preview.jpg';
|
||||
$media
|
||||
->filters()
|
||||
->resize(new \FFMpeg\Coordinate\Dimension(640, 480))
|
||||
->synchronize();
|
||||
$media
|
||||
->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds($this->args['second']))
|
||||
->save($this->outDir . $name);
|
||||
|
||||
/** Upload preview**/
|
||||
console::info('Uploading ' . $name);
|
||||
|
||||
$this->getVideoDevice($this->project->getId())->write(
|
||||
$path . $name,
|
||||
(new Local('/'))->read($this->outDir . $name)
|
||||
);
|
||||
|
||||
$preview = $this->database->createDocument('videos_previews', new Document([
|
||||
'videoId' => $this->video->getId(),
|
||||
'type' => 'preview',
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
]));
|
||||
|
||||
$this->video->setAttribute('previewId', $preview->getId());
|
||||
|
||||
$this->database->updateDocument(
|
||||
'videos',
|
||||
$this->video->getId(),
|
||||
new document(
|
||||
array_filter((array)$this->video, fn ($value) => !is_null($value))
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->action === 'timeline') {
|
||||
$interval = 2;
|
||||
$ranges = [
|
||||
['from' => 120, 'to' => 600, 'interval' => 5],
|
||||
@@ -194,74 +243,67 @@ class TranscodingV1 extends Worker
|
||||
$timeline['height'] = round($timeline['width'] / $timeline['aspect']);
|
||||
$timeline['size'] = '5x5';
|
||||
|
||||
$result = shell_exec("/usr/bin/ffmpeg -i " . $inPath . " -hide_banner -loglevel error -vsync vfr -vf 'select=isnan(prev_selected_t)+gte(t-prev_selected_t\," . $interval . "),scale=" . $timeline['width'] . ":" . $timeline['height'] . ",tile=" . $timeline['size'] . "' -qscale:v 3 " . $this->outDir . "img%d.jpg");
|
||||
$cmd = [
|
||||
'/usr/bin/ffmpeg',
|
||||
'-i ' . $inPath,
|
||||
'-hide_banner',
|
||||
'-loglevel error',
|
||||
'-vsync vfr',
|
||||
'-vf \'select=isnan(prev_selected_t)+gte(t-prev_selected_t\,' . $interval . '),scale=' . $timeline['width'] . ':' . $timeline['height'] . ',tile=' . $timeline['size'] . '\'',
|
||||
'-qscale:v 3',
|
||||
$this->outDir . 'sprite%d.jpg',
|
||||
];
|
||||
$result = shell_exec(implode(" ", $cmd));
|
||||
|
||||
/** Vtt creation**/
|
||||
if ($result !== false) {
|
||||
$size = explode('x', $timeline['size']);
|
||||
$counter = 0;
|
||||
$images = ceil(($this->video->getAttribute('duration') / $interval) / ($size[0] * $size[1]));
|
||||
$images = ceil((($this->video->getAttribute('duration') / 1000) / $interval) / ($size[0] * $size[1]));
|
||||
$data = "WEBVTT";
|
||||
$path = $this->getVideoDevice($this->project->getId())->getPath($this->video->getId()) . '/timeline/';
|
||||
for ($image = 1; $image <= $images; $image++) {
|
||||
$sprite = $this->database->createDocument('videos_previews', new Document([
|
||||
'videoId' => $this->video->getId(),
|
||||
'type' => 'sprite',
|
||||
'name' => 'sprite' . $image . '.jpg',
|
||||
'path' => $path,
|
||||
]));
|
||||
|
||||
$url = 'http://127.0.0.1/v1/videos/' . $this->video->getId() . '/preview/' . $sprite->getId();
|
||||
for ($col = 0; $col < $size[0]; $col++) {
|
||||
for ($row = 0; $row < $size[1]; $row++) {
|
||||
$data .= "\n" . gmdate("H:i:s", $counter * $interval) . " --> " . gmdate("H:i:s", ($counter + 1) * $interval) . "\n" . "/img" . $image . ".png#xywh=" . ($row * $timeline['width']) . "," . ($col * $timeline['height']) . "," . $timeline['width'] . "," . $timeline['height'];
|
||||
$data .= "\n" . gmdate("H:i:s", $counter * $interval) . " --> " . gmdate("H:i:s", ($counter + 1) * $interval) . "\n" . $url . "#xywh=" . ($row * $timeline['width']) . "," . ($col * $timeline['height']) . "," . $timeline['width'] . "," . $timeline['height'];
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($counter > 0) {
|
||||
/** Upload vtt**/
|
||||
$this->getVideoDevice($this->project->getId())->write(
|
||||
$this->getVideoDevice($this->project->getId())->getPath($this->video->getId()) . '/timeline/' . 'timeline.vtt',
|
||||
$this->getVideoDevice($this->project->getId())->getPath($this->video->getId() . '/timeline/timeline.vtt'),
|
||||
$data
|
||||
);
|
||||
|
||||
console::info('Uploading timeline vtt');
|
||||
|
||||
/** Upload**/
|
||||
/** Upload sprites**/
|
||||
$dir = new DirectoryIterator($this->outDir);
|
||||
foreach ($dir as $fileinfo) {
|
||||
if (!$fileinfo->isDot()) {
|
||||
console::info('Uploading ' . $fileinfo->getFilename());
|
||||
$this->getVideoDevice($this->project->getId())->write(
|
||||
$this->getVideoDevice($this->project->getId())->getPath($this->video->getId() . '/timeline') . '/' . $fileinfo->getFilename(),
|
||||
$path . $fileinfo->getFilename(),
|
||||
(new Local('/'))->read($this->outDir . $fileinfo->getFilename())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->database->updateDocument('videos', $this->video->getId(), $this->video->setAttribute('timeline', true));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->action === 'preview') {
|
||||
$media
|
||||
->filters()
|
||||
->resize(new \FFMpeg\Coordinate\Dimension(640, 480))
|
||||
->synchronize();
|
||||
$media
|
||||
->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(5))
|
||||
->save($this->outDir . ' 640x480.png');
|
||||
|
||||
/** Upload**/
|
||||
$dir = new DirectoryIterator($this->outDir);
|
||||
foreach ($dir as $fileinfo) {
|
||||
if (!$fileinfo->isDot()) {
|
||||
console::info('Uploading ' . $fileinfo->getFilename());
|
||||
$this->getVideoDevice($this->project->getId())->write(
|
||||
$this->getVideoDevice($this->project->getId())->getPath($this->video->getId() . '/preview') . '/' . $fileinfo->getFilename(),
|
||||
(new Local('/'))->read($this->outDir . $fileinfo->getFilename())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->database->updateDocument('videos', $this->video->getId(), $this->video->setAttribute('preview', true));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subs = [];
|
||||
$subtitles = $this->database->find('videos_subtitles', [
|
||||
Query::equal('videoId', [$this->video->getId()]),
|
||||
@@ -426,7 +468,7 @@ class TranscodingV1 extends Worker
|
||||
$query->setAttribute('path', $renditionPath);
|
||||
$this->database->updateDocument('videos_renditions', $query->getId(), $query);
|
||||
$this->send($query, 'update');
|
||||
console::info('Uploading to [' . $to . ']');
|
||||
console::info('Uploading to ' . $to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class Transcoding extends Event
|
||||
{
|
||||
protected ?Document $video = null;
|
||||
protected ?Document $profile = null;
|
||||
protected int $second = 5;
|
||||
protected string $output = '';
|
||||
protected string $action = '';
|
||||
|
||||
@@ -109,6 +110,30 @@ class Transcoding extends Event
|
||||
return $this->profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets second from duration.
|
||||
*
|
||||
* @param int $second
|
||||
* @return self
|
||||
*/
|
||||
public function setSecond(int $second): self
|
||||
{
|
||||
$this->second = $second;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns second from duration.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getSecond(): int
|
||||
{
|
||||
return $this->second;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Executes the function event and sends it to the functions worker.
|
||||
*
|
||||
@@ -118,10 +143,11 @@ class Transcoding extends Event
|
||||
public function trigger(): string|bool
|
||||
{
|
||||
$keys = [
|
||||
'action' => $this->action,
|
||||
'project' => $this->project,
|
||||
'user' => $this->user,
|
||||
'video' => $this->video,
|
||||
'action' => $this->action,
|
||||
'user' => $this->user,
|
||||
'video' => $this->video,
|
||||
'second' => $this->getSecond(),
|
||||
];
|
||||
|
||||
if (!empty($this->getOutput())) {
|
||||
|
||||
@@ -183,7 +183,9 @@ class Exception extends \Exception
|
||||
/** Video */
|
||||
public const VIDEO_PROFILE_NOT_FOUND = 'video_profile_not_found';
|
||||
public const VIDEO_NOT_VALID = 'video_not_valid';
|
||||
public const VIDEO_SECOND_OUT_OF_RANGE = 'video_second_out_of_range';
|
||||
public const VIDEO_TIMELINE_NOT_FOUND = 'video_timeline_not_found';
|
||||
public const VIDEO_PREVIEW_NOT_FOUND = 'video_preview_not_found';
|
||||
public const VIDEO_RENDITION_NOT_FOUND = 'video_rendition_not_found';
|
||||
public const VIDEO_SUBTITLE_NOT_FOUND = 'video_subtitle_not_found';
|
||||
public const VIDEO_NOT_FOUND = 'video_not_found';
|
||||
|
||||
@@ -28,6 +28,12 @@ class Video extends Model
|
||||
'default' => '',
|
||||
'example' => 'd5fg5ehg1c168g7c',
|
||||
])
|
||||
->addRule('previewId', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'File preview image ID.',
|
||||
'default' => '',
|
||||
'example' => 'd5fg5ehg56c168g5b',
|
||||
])
|
||||
->addRule('size', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'File size.',
|
||||
|
||||
@@ -85,7 +85,6 @@ class VideosCustomClientTest extends Scope
|
||||
|
||||
$this->assertEquals($file['headers']['status-code'], 401);
|
||||
|
||||
|
||||
/**
|
||||
* Create subtitles
|
||||
*/
|
||||
@@ -97,7 +96,7 @@ class VideosCustomClientTest extends Scope
|
||||
'bucketId' => $this->getBucket()['$id'],
|
||||
'fileId' => $this->getSubtitle()['$id'],
|
||||
'name' => 'English',
|
||||
'code' => 'Eng',
|
||||
'code' => 'eng',
|
||||
'default' => true,
|
||||
]);
|
||||
|
||||
@@ -118,7 +117,6 @@ class VideosCustomClientTest extends Scope
|
||||
|
||||
$this->assertEquals(404, $response['headers']['status-code']);
|
||||
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/videos/' . $videoId . '/subtitles', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
@@ -253,8 +251,8 @@ class VideosCustomClientTest extends Scope
|
||||
$subsCount = 0;
|
||||
$isVideo = false;
|
||||
$isAudio = false;
|
||||
$subs[] = ['id' => '2', 'lang' => 'Eng',];
|
||||
$subs[] = ['id' => '3', 'lang' => 'It',];
|
||||
$subs[] = ['id' => '2', 'lang' => 'eng',];
|
||||
$subs[] = ['id' => '3', 'lang' => 'it',];
|
||||
foreach ($xml->Period->AdaptationSet as $adaptation) {
|
||||
if ((string)$adaptation['contentType'] === 'video') {
|
||||
$isVideo = true;
|
||||
|
||||
@@ -216,7 +216,94 @@ class VideosCustomServerTest extends Scope
|
||||
$this->assertNotEmpty($response['body']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
|
||||
return $response['body']['$id'];
|
||||
$videoId = $response['body']['$id'];
|
||||
|
||||
/**
|
||||
* Create preview
|
||||
*/
|
||||
$response = $this->client->call(Client::METHOD_POST, '/v1/videos/' . $videoId . '/preview', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'second' => 'one',
|
||||
]);
|
||||
$this->assertEquals(400, $response['headers']['status-code']);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/v1/videos/' . $videoId . '/preview', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'second' => 9999999,
|
||||
]);
|
||||
$this->assertEquals(400, $response['headers']['status-code']);
|
||||
|
||||
sleep(5);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/v1/videos/' . $videoId . '/preview', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'second' => 10,
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertEquals(92810, $response['body']['duration']);
|
||||
/**
|
||||
* timeline vtt
|
||||
*/
|
||||
$response = $this->client->call(Client::METHOD_GET, '/v1/videos/' . $videoId . '/timeline', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']);
|
||||
$this->assertEquals(2956, strlen($response['body']));
|
||||
|
||||
$response = $this->client->call(Client::METHOD_GET, '/v1/videos/' . $videoId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']);
|
||||
|
||||
$previewId = $response['body']['previewId'];
|
||||
|
||||
/**
|
||||
* preview image
|
||||
*/
|
||||
$response = $this->client->call(Client::METHOD_GET, '/v1/videos/' . $videoId . '/preview/' . $previewId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']);
|
||||
$this->assertEquals('image/jpeg', $response['headers']['content-type']);
|
||||
$this->assertEquals('miss', $response['headers']['x-appwrite-cache']);
|
||||
|
||||
sleep(3);
|
||||
|
||||
/**
|
||||
* response from cache
|
||||
*/
|
||||
$response = $this->client->call(Client::METHOD_GET, '/v1/videos/' . $videoId . '/preview/' . $previewId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
$this->assertNotEmpty($response['body']);
|
||||
$this->assertEquals('image/jpeg', $response['headers']['content-type']);
|
||||
$this->assertEquals('hit', $response['headers']['x-appwrite-cache']);
|
||||
|
||||
return $videoId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user