diff --git a/Dockerfile b/Dockerfile index 553bf63d8a..dd3020253c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -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" diff --git a/app/config/collections.php b/app/config/collections.php index 7b53a74f2a..709ab7aa34 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -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', diff --git a/app/config/errors.php b/app/config/errors.php index 555def3d58..03ad890349 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -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.', diff --git a/app/controllers/api/videos.php b/app/controllers/api/videos.php index d36cf9c6c2..c00967484d 100644 --- a/app/controllers/api/videos.php +++ b/app/controllers/api/videos.php @@ -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') diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5655874c65..d5c5261311 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -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, diff --git a/app/init.php b/app/init.php index 1b3ad376f0..eebca4c04a 100644 --- a/app/init.php +++ b/app/init.php @@ -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; diff --git a/app/workers/transcoding.php b/app/workers/transcoding.php index 2ed076e34f..38ce0783aa 100644 --- a/app/workers/transcoding.php +++ b/app/workers/transcoding.php @@ -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); } } } diff --git a/src/Appwrite/Event/Transcoding.php b/src/Appwrite/Event/Transcoding.php index a3cdadf2f2..6c34ab7ee8 100644 --- a/src/Appwrite/Event/Transcoding.php +++ b/src/Appwrite/Event/Transcoding.php @@ -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())) { diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 75d20d7dee..77eeb1757a 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -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'; diff --git a/src/Appwrite/Utopia/Response/Model/Video.php b/src/Appwrite/Utopia/Response/Model/Video.php index 14112913e4..7b8f73c9d1 100644 --- a/src/Appwrite/Utopia/Response/Model/Video.php +++ b/src/Appwrite/Utopia/Response/Model/Video.php @@ -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.', diff --git a/tests/e2e/Services/Videos/VideosCustomClientTest.php b/tests/e2e/Services/Videos/VideosCustomClientTest.php index eac361627c..120a988ed8 100644 --- a/tests/e2e/Services/Videos/VideosCustomClientTest.php +++ b/tests/e2e/Services/Videos/VideosCustomClientTest.php @@ -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; diff --git a/tests/e2e/Services/Videos/VideosCustomServerTest.php b/tests/e2e/Services/Videos/VideosCustomServerTest.php index 6c1a42708b..a84d294343 100644 --- a/tests/e2e/Services/Videos/VideosCustomServerTest.php +++ b/tests/e2e/Services/Videos/VideosCustomServerTest.php @@ -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; } /**