diff --git a/app/config/collections.php b/app/config/collections.php index b4b9f47476..7b53a74f2a 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -3575,6 +3575,28 @@ $collections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => 'timeline', + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => 'preview', + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => false, + 'array' => false, + 'filters' => [], + ], [ '$id' => 'size', 'type' => Database::VAR_INTEGER, diff --git a/app/config/errors.php b/app/config/errors.php index eb1594a4dd..555def3d58 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -584,6 +584,11 @@ return [ 'description' => 'Video not found.', 'code' => 404, ], + Exception::VIDEO_TIMELINE_NOT_FOUND => [ + 'name' => Exception::VIDEO_TIMELINE_NOT_FOUND, + 'description' => 'Video timeline not found.', + 'code' => 404, + ], Exception::VIDEO_RENDITION_SEGMENT_NOT_FOUND => [ 'name' => Exception::VIDEO_RENDITION_SEGMENT_NOT_FOUND, 'description' => 'Video rendition segment not found', diff --git a/app/config/locale/languages.php b/app/config/locale/languages.php index 5ecac964cf..3860becfa7 100644 --- a/app/config/locale/languages.php +++ b/app/config/locale/languages.php @@ -1112,4 +1112,4 @@ return [ "name" => "Chinese", "nativeName" => "中文" ], -]; \ No newline at end of file +]; diff --git a/app/controllers/api/videos.php b/app/controllers/api/videos.php index 46f4beea40..d36cf9c6c2 100644 --- a/app/controllers/api/videos.php +++ b/app/controllers/api/videos.php @@ -2,6 +2,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Transcoding; +use Appwrite\OpenSSL\OpenSSL; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Files; use Appwrite\Utopia\Response; @@ -14,8 +15,12 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Appwrite\Extend\Exception; +use Utopia\Image\Image; +use Utopia\Storage\Compression\Algorithms\GZIP; +use Utopia\Storage\Compression\Algorithms\Zstd; use Utopia\Storage\Device; use Utopia\Validator\Boolean; +use Utopia\Validator\HexColor; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -79,9 +84,10 @@ App::post('/v1/videos') ->param('fileId', '', new CustomId(), 'File ID. Choose your own unique ID or pass the string "unique()" to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->inject('request') ->inject('response') + ->inject('project') ->inject('dbForProject') ->inject('mode') - ->action(action: function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, string $mode) { + ->action(action: function (string $bucketId, string $fileId, Request $request, Response $response, Document $project, Database $dbForProject, string $mode) { $file = validateFilePermissions($dbForProject, $bucketId, $fileId, $mode); @@ -101,6 +107,18 @@ App::post('/v1/videos') ])); }); + (new Transcoding()) + ->setAction('preview') + ->setProject($project) + ->setVideo($video) + ->trigger(); + + (new Transcoding()) + ->setAction('timeline') + ->setProject($project) + ->setVideo($video) + ->trigger(); + $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($video, Response::MODEL_VIDEO); }); @@ -268,6 +286,203 @@ App::get('/v1/videos') ]), Response::MODEL_VIDEO_LIST); }); +App::get('/v1/videos/timeline/:videoId') + ->desc('Get video timeline ') + ->groups(['api', 'videos']) + ->label('scope', 'videos.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'videos') + ->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) + ->param('videoId', '', new UID(), 'Video unique ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('deviceVideos') + ->inject('mode') + ->action(function (string $videoId, Response $response, Database $dbForProject, Device $deviceVideos, 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); + + if (!$video->getAttribute('timeline')) { + throw new Exception(Exception::VIDEO_TIMELINE_NOT_FOUND); + } + + $data = $deviceVideos->read($deviceVideos->getPath($video->getId() . '/timeline') . '/timeline.vtt'); + $response->setContentType('text/vtt') + ->send($data); + }); + +App::get('/v1/videos/:videoId/preview') + ->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('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'videos') + ->label('sdk.method', 'getVideoImagePreview') + ->label('sdk.description', '/docs/references/storage/get-video-image-preview.md') + ->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('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) { + + if (!\extension_loaded('imagick')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); + } + + $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); + + if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && $mode !== APP_MODE_ADMIN)) { + throw new Exception(Exception::STORAGE_BUCKET_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); + } + + if ((\strpos($request->getAccept(), 'image/webp') === false) && ('webp' === $output)) { // Fallback webp to jpeg when no browser support + $output = 'jpg'; + } + + $inputs = Config::getParam('storage-inputs'); + $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); + } + + 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']; + + $response + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + 60 * 60 * 24 * 30) . ' GMT') + ->setContentType($contentType) + ->file($data) + ; + + unset($image); + }); + App::post('/v1/videos/:videoId/subtitles') ->desc('Add subtitle to video') ->groups(['api', 'videos']) @@ -285,7 +500,7 @@ App::post('/v1/videos/:videoId/subtitles') ->param('bucketId', '', new CustomId(), 'Subtitle bucket unique ID.') ->param('fileId', '', new CustomId(), 'Subtitle file unique ID.') ->param('name', '', new Text(32), 'Subtitle name.') - ->param('code', '', new Text(3), 'Subtitle ISO 639-2 three letters alpha code.') + ->param('code', '', new WhiteList(\array_column(Config::getParam('locale-languages'), 'code2')), 'Subtitle ISO 639-2 3 letters alpha code.') ->param('default', false, new Boolean(true), 'Default subtitle.') ->inject('request') ->inject('response') @@ -293,14 +508,6 @@ App::post('/v1/videos/:videoId/subtitles') ->inject('mode') ->action(action: function (string $videoId, string $bucketId, string $fileId, string $name, string $code, bool $default, Request $request, Response $response, Database $dbForProject, string $mode) { - $code = strtolower($code); - $languages = Config::getParam('locale-languages'); - $found = array_search($code, array_column($languages, 'code2')); - - if (!$found) { - throw new Exception(Exception::VIDEO_LANGUAGE_CODE_NOT_VALID); - } - $video = Authorization::skip(fn() => $dbForProject->getDocument('videos', $videoId)); if ($video->isEmpty()) { @@ -490,10 +697,11 @@ App::post('/v1/videos/:videoId/rendition') $transcoder = new Transcoding(); $transcoder - ->setOutput($output) + ->setAction('encode') ->setProject($project) ->setVideo($video) ->setProfile($profile) + ->setOutput($output) ->trigger(); $response->noContent(); @@ -817,7 +1025,7 @@ App::get('/v1/videos/:videoId/outputs/:output/renditions/:renditionId/streams/:s ->param('videoId', null, new UID(), 'Video unique ID.') ->param('output', '', new WhiteList(['hls']), 'Output name.') ->param('renditionId', null, new UID(), 'Rendition unique ID.') - ->param('streamId', '', new Range(0, 10), 'Stream ID.') + ->param('streamId', 0, new Range(0, 10), 'Stream ID.') ->inject('response') ->inject('dbForProject') ->inject('mode') diff --git a/app/init.php b/app/init.php index ca3f06bc1e..1b3ad376f0 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 = '6408a2e6c8b85007caa1'; + $projectId = '6425c03a3ce2f21b2d1d'; if ($projectId === 'console') { return $console; diff --git a/app/views/videos/hls-master.phtml b/app/views/videos/hls-master.phtml index 2265aada07..557ccc8497 100644 --- a/app/views/videos/hls-master.phtml +++ b/app/views/videos/hls-master.phtml @@ -1,12 +1,12 @@ #EXTM3U #EXT-X-VERSION:3 -getParam('audios', []) as $audio): ?> +getParam('audios', []) as $audio):?> #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="group_audio",NAME="",DEFAULT=,LANGUAGE="",URI="" - -getParam('subtitles', []) as $subtitle): ?> + +getParam('subtitles', []) as $subtitle):?> #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="escape($subtitle['name']); ?>",DEFAULT=,AUTOSELECT=NO,FORCED=NO,LANGUAGE="escape($subtitle['code']); ?>",URI="" - -getParam('renditions', []) as $rendition): ?> + +getParam('renditions', []) as $rendition):?> #EXT-X-STREAM-INF:BANDWIDTH="",RESOLUTION="",NAME="",SUBTITLES="",AUDIO="" - - \ No newline at end of file + + \ No newline at end of file diff --git a/app/workers/transcoding.php b/app/workers/transcoding.php index 70163c2879..2ed076e34f 100644 --- a/app/workers/transcoding.php +++ b/app/workers/transcoding.php @@ -57,6 +57,8 @@ class TranscodingV1 extends Worker private Document $video; + private string $action; + private Document $profile; private Document $project; @@ -76,12 +78,13 @@ class TranscodingV1 extends Worker public function init(): void { - $this->video = new Document($this->args['video']); - $this->profile = new Document($this->args['profile']); - $this->project = new Document($this->args['project']); - $this->basePath .= $this->video->getId() . '/' . $this->profile->getId(); - $this->inDir = $this->basePath . '/in/'; - $this->outDir = $this->basePath . '/out/'; + $this->video = new Document($this->args['video'] ?? []); + $this->profile = new Document($this->args['profile'] ?? []); + $this->project = new Document($this->args['project'] ?? []); + $this->action = $this->args['action']; + $this->basePath .= uniqid(); + $this->inDir = $this->basePath . '/in/'; + $this->outDir = $this->basePath . '/out/'; @mkdir($this->inDir, 0755, true); @mkdir($this->outDir, 0755, true); $this->outPath = $this->outDir . $this->video->getId(); @@ -125,48 +128,140 @@ class TranscodingV1 extends Worker console::error('Not an valid Video file "' . $inPath . '"'); } - /** - * Original asset media info - */ - $mediaInfo = new MediaInfo(); - $mediaInfoContainer = $mediaInfo->getInfo($inPath); - $general = $mediaInfoContainer->getGeneral(); - $this->video - ->setAttribute('duration', $general->has('duration') ? strval($general->get('duration')->getMilliseconds()) : '') - ->setAttribute('format', $general->has('format') ? $general->get('format')->getShortName() : ''); - - foreach ($mediaInfoContainer->getVideos() ?? [] as $video) { - $this->video - ->setAttribute('height', $video->has('height') ? $video->get('height')->getAbsoluteValue() : 0) - ->setAttribute('width', $video->has('width') ? $video->get('width')->getAbsoluteValue() : 0) - ->setAttribute('aspectRatio', $video->has('display_aspect_ratio') ? $video->get('display_aspect_ratio')->getTextValue() : '') - ->setAttribute('videoFormat', $video->has('format') ? $video->get('format')->getShortName() : '') - ->setAttribute('videoFormatProfile', $video->has('format_profile') ? $video->get('format_profile') : '') - ->setAttribute('videoFrameRate', $video->has('frame_rate') ? strval($video->get('frame_rate')->getAbsoluteValue()) : '') - ->setAttribute('videoFrameRateMode', $video->has('frame_rate_mode') ? $video->get('frame_rate_mode')->getFullName() : '') - ->setAttribute('videoBitRate', $video->has('bit_rate') ? strval($video->get('bit_rate')->getAbsoluteValue()) : ''); - } - - foreach ($mediaInfoContainer->getAudios() ?? [] as $audio) { - $this->video - ->setAttribute('audioFormat', $audio->has('format') ? strval($audio->get('format')->getShortName()) : '') - ->setAttribute('audioSampleRate', $audio->has('sampling_rate') ? strval($audio->get('sampling_rate')->getAbsoluteValue()) : '') - ->setAttribute('audioBitRate', $audio->has('bit_rate') ? strval($audio->get('bit_rate')->getAbsoluteValue()) : ''); - } - - 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 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); - $media = $this->ffmpeg->open($inPath); + if ($this->action === 'timeline') { + /** + * Original asset metadata + */ + $mediaInfo = new MediaInfo(); + $mediaInfoContainer = $mediaInfo->getInfo($inPath); + $general = $mediaInfoContainer->getGeneral(); + $this->video + ->setAttribute('duration', $general->has('duration') ? strval($general->get('duration')->getMilliseconds()) : '') + ->setAttribute('format', $general->has('format') ? $general->get('format')->getShortName() : ''); + + foreach ($mediaInfoContainer->getVideos() ?? [] as $video) { + $this->video + ->setAttribute('height', $video->has('height') ? $video->get('height')->getAbsoluteValue() : 0) + ->setAttribute('width', $video->has('width') ? $video->get('width')->getAbsoluteValue() : 0) + ->setAttribute('aspectRatio', $video->has('display_aspect_ratio') ? $video->get('display_aspect_ratio')->getTextValue() : '') + ->setAttribute('videoFormat', $video->has('format') ? $video->get('format')->getShortName() : '') + ->setAttribute('videoFormatProfile', $video->has('format_profile') ? $video->get('format_profile') : '') + ->setAttribute('videoFrameRate', $video->has('frame_rate') ? strval($video->get('frame_rate')->getAbsoluteValue()) : '') + ->setAttribute('videoFrameRateMode', $video->has('frame_rate_mode') ? $video->get('frame_rate_mode')->getFullName() : '') + ->setAttribute('videoBitRate', $video->has('bit_rate') ? strval($video->get('bit_rate')->getAbsoluteValue()) : ''); + } + + foreach ($mediaInfoContainer->getAudios() ?? [] as $audio) { + $this->video + ->setAttribute('audioFormat', $audio->has('format') ? strval($audio->get('format')->getShortName()) : '') + ->setAttribute('audioSampleRate', $audio->has('sampling_rate') ? strval($audio->get('sampling_rate')->getAbsoluteValue()) : '') + ->setAttribute('audioBitRate', $audio->has('bit_rate') ? strval($audio->get('bit_rate')->getAbsoluteValue()) : ''); + } + + 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 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); + + $interval = 2; + $ranges = [ + ['from' => 120, 'to' => 600, 'interval' => 5], + ['from' => 600, 'to' => 1800 , 'interval' => 10], + ['from' => 1800, 'to' => 3600, 'interval' => 20], + ['from' => 3600, 'to' => 99999, 'interval' => 30], + ]; + + foreach ($ranges as $range) { + if ( + $this->video->getAttribute('duration') > $range['from'] && + $this->video->getAttribute('duration') <= $range['to'] + ) { + $interval = $range['interval']; + break; + } + } + + $timeline['aspect'] = $this->video->getAttribute('width') / $this->video->getAttribute('height'); + $timeline['width'] = 160; + $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"); + + if ($result !== false) { + $size = explode('x', $timeline['size']); + $counter = 0; + $images = ceil(($this->video->getAttribute('duration') / $interval) / ($size[0] * $size[1])); + $data = "WEBVTT"; + for ($image = 1; $image <= $images; $image++) { + 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']; + $counter++; + } + } + } + + if ($counter > 0) { + $this->getVideoDevice($this->project->getId())->write( + $this->getVideoDevice($this->project->getId())->getPath($this->video->getId()) . '/timeline/' . 'timeline.vtt', + $data + ); + + console::info('Uploading timeline vtt'); + + /** 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() . '/timeline') . '/' . $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()]), @@ -311,33 +406,29 @@ class TranscodingV1 extends Worker console::info('Rendition ' . $query->getId() . ' conversion, done'); - /** Upload & cleanup **/ - $start = 0; - $fileNames = scandir($this->outDir); - foreach ($fileNames as $fileName) { - if ($fileName === '.' || $fileName === '..') { - //str_contains($fileName, '.json')) { - continue; - } + /** Upload**/ + $dir = new DirectoryIterator($this->outDir); + foreach ($dir as $fileinfo) { + if (!$fileinfo->isDot()) { + console::info('Uploading ' . $fileinfo->getFilename()); - $data = (new Local('/'))->read($this->outDir . $fileName); - $to = $renditionPath; - if (str_contains($fileName, "_subtitles_") || str_contains($fileName, ".vtt")) { - $to = $renditionRootPath; - } + $data = (new Local('/'))->read($this->outDir . $fileinfo->getFilename()); + $to = $renditionPath; + if (str_contains($fileinfo->getFilename(), "_subtitles_") || str_contains($fileinfo->getFilename(), ".vtt")) { + $to = $renditionRootPath; + } - $this->getVideoDevice($this->project->getId())->write($to . $fileName, $data, \mime_content_type($this->outDir . $fileName)); - if ($start === 0) { - $query->setAttribute('progress', '100'); - $query->setAttribute('status', self::STATUS_UPLOADING); - $query->setAttribute('path', $renditionPath); - $this->database->updateDocument('videos_renditions', $query->getId(), $query); - $this->send($query, 'update'); - $start = 1; + $this->getVideoDevice($this->project->getId())->write($to . $fileinfo->getFilename(), $data); - console::info('Uploading to [' . $to . ']'); + if ($fileinfo->key() === 0) { + $query->setAttribute('progress', '100'); + $query->setAttribute('status', self::STATUS_UPLOADING); + $query->setAttribute('path', $renditionPath); + $this->database->updateDocument('videos_renditions', $query->getId(), $query); + $this->send($query, 'update'); + console::info('Uploading to [' . $to . ']'); + } } - @unlink($this->outDir . $fileName); } $query->setAttribute('status', self::STATUS_READY); @@ -376,7 +467,6 @@ class TranscodingV1 extends Worker '-dn', '-sn', '-vf', 'scale=iw:-2:force_original_aspect_ratio=increase,setsar=1:1', - '-b_strategy', '1', '-bf', '3', '-force_key_frames', 'expr:gte(t,n_forced*2)' //enforce strict key frame ]; diff --git a/docker-compose.yml b/docker-compose.yml index c5eb2d5c9c..083a5f76f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,7 @@ services: - ./src:/usr/src/code/src - ./dev:/usr/local/dev - ./tests/tmp:/tmp - #- ./vendor/utopia-php/database:/usr/src/code/vendor/utopia-php/database + - ./vendor/utopia-php/framework:/usr/src/code/vendor/utopia-php/framework depends_on: - mariadb - redis diff --git a/src/Appwrite/Event/Transcoding.php b/src/Appwrite/Event/Transcoding.php index 7f199701ff..a3cdadf2f2 100644 --- a/src/Appwrite/Event/Transcoding.php +++ b/src/Appwrite/Event/Transcoding.php @@ -7,11 +7,10 @@ use Utopia\Database\Document; class Transcoding extends Event { - protected Document $video; - - protected Document $profile; - - protected string $output; + protected ?Document $video = null; + protected ?Document $profile = null; + protected string $output = ''; + protected string $action = ''; public function __construct() { @@ -41,6 +40,28 @@ class Transcoding extends Event return $this->output; } + /** + * Sets action. + * + * @param string $action + * @return self + */ + public function setAction(string $action): self + { + $this->action = $action; + + return $this; + } + + /** + * Returns action. + * + * @return string + */ + public function getAction(): string + { + return $this->action; + } /** * Sets video. @@ -96,12 +117,25 @@ class Transcoding extends Event */ public function trigger(): string|bool { - return Resque::enqueue($this->queue, $this->class, [ - 'output' => $this->output, + $keys = [ 'project' => $this->project, - 'user' => $this->user, - 'video' => $this->video, - 'profile' => $this->profile, - ]); + 'user' => $this->user, + 'video' => $this->video, + 'action' => $this->action, + ]; + + if (!empty($this->getOutput())) { + $keys = array_merge($keys, [ + 'output' => $this->getOutput(), + ]); + } + + if (!empty($this->getProfile())) { + $keys = array_merge($keys, [ + 'profile' => $this->getProfile(), + ]); + } + + return Resque::enqueue($this->queue, $this->class, $keys); } } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 7f380a7e8e..75d20d7dee 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -183,6 +183,7 @@ 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_TIMELINE_NOT_FOUND = 'video_timeline_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/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index cdfea23b51..6f3834e0d6 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -336,7 +336,6 @@ class Realtime extends Adapter $channels[] = 'renditions.' . $payload->getId(); $channels[] = 'videos.' . $payload->getAttribute('videoId'); $roles = $payload->getRead(); - } break;