diff --git a/composer.json b/composer.json index 2f2f679dd62..6b59456bb7b 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "composer/composer": "^1.10.22 || ^2.0.13", "facade/ignition-contracts": "^1.0", "guzzlehttp/guzzle": "^6.3 || ^7.0", + "james-heinrich/getid3": "^1.9", "laravel/framework": "^6.20.14 || ^7.30.4 || ^8.24.0", "laravel/helpers": "^1.1", "league/commonmark": "^1.5", diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index ffc94674fdc..820a4af4b3b 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -2,7 +2,7 @@ namespace Statamic\Assets; -use Facades\Statamic\Assets\Dimensions; +use Facades\Statamic\Assets\Attributes; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Statamic\Contracts\Assets\Asset as AssetContract; @@ -159,14 +159,15 @@ public function generateMeta() $meta = ['data' => $this->data->all()]; if ($this->exists()) { - $dimensions = Dimensions::asset($this)->get(); + $attributes = Attributes::asset($this)->get(); $meta = array_merge($meta, [ 'size' => $this->disk()->size($this->path()), 'last_modified' => $this->disk()->lastModified($this->path()), - 'width' => $dimensions[0], - 'height' => $dimensions[1], + 'width' => Arr::get($attributes, 'width'), + 'height' => Arr::get($attributes, 'height'), 'mime_type' => $this->disk()->mimeType($this->path()), + 'duration' => Arr::get($attributes, 'duration'), ]); } @@ -548,7 +549,7 @@ public function move($folder, $filename = null) */ public function dimensions() { - if (! $this->isImage() && ! $this->isSvg()) { + if (! $this->hasDimensions()) { return [null, null]; } @@ -600,7 +601,7 @@ public function orientation() */ public function ratio() { - if (! $this->isImage() && ! $this->isSvg()) { + if (! $this->hasDimensions()) { return null; } @@ -785,4 +786,9 @@ public function shallowAugmentedArrayKeys() { return ['id', 'url', 'permalink', 'api_url']; } + + private function hasDimensions() + { + return $this->isImage() || $this->isSvg() || $this->isVideo(); + } } diff --git a/src/Assets/Attributes.php b/src/Assets/Attributes.php new file mode 100644 index 00000000000..2e3af7a8f2c --- /dev/null +++ b/src/Assets/Attributes.php @@ -0,0 +1,173 @@ +generator = $generator; + } + + public function asset(Asset $asset) + { + $this->asset = $asset; + + return $this; + } + + /** + * Get the attributes of an asset. + * + * @return array + */ + public function get() + { + if ($this->asset->isAudio()) { + return $this->getAudioAttributes(); + } + + if ($this->asset->isImage()) { + return $this->getImageAttributes(); + } + + if ($this->asset->isSvg()) { + return $this->getSvgAttributes(); + } + + if ($this->asset->isVideo()) { + return $this->getVideoAttributes(); + } + + return []; + } + + /** + * Get the attributes of a sound. + * + * @return array + */ + private function getAudioAttributes() + { + $id3 = ExtractInfo::fromAsset($this->asset); + + $length = Arr::get($id3, 'playtime_seconds', 0); + + return ['duration' => $length]; + } + + /** + * Get the attributes of an image. + * + * @return array + */ + private function getImageAttributes() + { + // Since assets may be located on external platforms like Amazon S3, we can't simply + // grab the attributes. So we'll copy it locally and read the attributes from there. + $manager = new MountManager([ + 'source' => $this->asset->disk()->filesystem()->getDriver(), + 'cache' => $cache = $this->getCacheFlysystem(), + ]); + + $cachePath = "{$this->asset->containerId()}/{$this->asset->path()}"; + + if ($manager->has($destination = "cache://{$cachePath}")) { + $manager->delete($destination); + } + + $manager->copy("source://{$this->asset->path()}", $destination); + + try { + [$width, $height] = getimagesize($cache->getAdapter()->getPathPrefix().$cachePath); + $size = compact('width', 'height'); + } catch (\Exception $e) { + $size = []; + } finally { + $cache->delete($cachePath); + } + + return $size; + } + + /** + * Get the attributes of an SVG. + * + * @return array + */ + private function getSvgAttributes() + { + // Since assets may be located on external platforms like Amazon S3, we can't simply + // grab the attributes. So we'll copy it locally and read the attributes from there. + $manager = new MountManager([ + 'source' => $this->asset->disk()->filesystem()->getDriver(), + 'cache' => $cache = $this->getCacheFlysystem(), + ]); + + $cachePath = "{$this->asset->containerId()}/{$this->asset->path()}"; + + if ($manager->has($destination = "cache://{$cachePath}")) { + $manager->delete($destination); + } + + $manager->copy("source://{$this->asset->path()}", $destination); + + $svg = simplexml_load_file($cache->getAdapter()->getPathPrefix().$cachePath); + + $cache->delete($cachePath); + + if ($svg['width'] && $svg['height'] + && is_numeric((string) $svg['width']) + && is_numeric((string) $svg['height'])) { + return ['width' => (float) $svg['width'], 'height' => (float) $svg['height']]; + } elseif ($svg['viewBox']) { + [,,$width, $height] = preg_split('/[\s,]+/', $svg['viewBox'] ?: ''); + + return compact('width', 'height'); + } + + return ['width' => 300, 'height' => 150]; + } + + /** + * Get the attributes of a video. + * + * @return array + */ + private function getVideoAttributes() + { + $id3 = ExtractInfo::fromAsset($this->asset); + + return [ + 'width' => Arr::get($id3, 'video.resolution_x'), + 'height' => Arr::get($id3, 'video.resolution_y'), + 'duration' => Arr::get($id3, 'playtime_seconds'), + ]; + } + + private function getCacheFlysystem() + { + $disk = 'attributes-cache'; + + config(["filesystems.disks.{$disk}" => [ + 'driver' => 'local', + 'root' => storage_path('statamic/attributes-cache'), + ]]); + + return Storage::disk($disk)->getDriver(); + } +} diff --git a/src/Assets/Dimensions.php b/src/Assets/Dimensions.php index 0cb410a61c8..6ca2875d97e 100644 --- a/src/Assets/Dimensions.php +++ b/src/Assets/Dimensions.php @@ -2,149 +2,25 @@ namespace Statamic\Assets; -use Illuminate\Support\Facades\Storage; -use League\Flysystem\MountManager; -use Statamic\Imaging\ImageGenerator; - -class Dimensions +/** + * @deprecated + */ +class Dimensions extends Attributes { - /** - * @var Asset - */ - private $asset; - - /** - * @param $generator ImageGenerator - */ - public function __construct(ImageGenerator $generator) - { - $this->generator = $generator; - } - - public function asset(Asset $asset) - { - $this->asset = $asset; - - return $this; - } - - /** - * Get the dimensions of an asset. - * - * @return array - */ public function get() { - if ($this->asset->isImage()) { - return $this->getImageDimensions(); - } elseif ($this->asset->isSvg()) { - return $this->getSvgDimensions(); - } + $attrs = parent::get(); - return [null, null]; + return [$attrs['width'] ?? null, $attrs['height'] ?? null]; } - /** - * Get the width of the asset. - * - * @return int - */ public function width() { return array_get($this->get(), 0); } - /** - * Get the height of the asset. - * - * @return int - */ public function height() { return array_get($this->get(), 1); } - - /** - * Get the dimensions of an image. - * - * @return array - */ - private function getImageDimensions() - { - // Since assets may be located on external platforms like Amazon S3, we can't simply - // grab the dimensions. So we'll copy it locally and read the dimensions from there. - $manager = new MountManager([ - 'source' => $this->asset->disk()->filesystem()->getDriver(), - 'cache' => $cache = $this->getCacheFlysystem(), - ]); - - $cachePath = "{$this->asset->containerId()}/{$this->asset->path()}"; - - if ($manager->has($destination = "cache://{$cachePath}")) { - $manager->delete($destination); - } - - $manager->copy("source://{$this->asset->path()}", $destination); - - try { - $size = getimagesize($cache->getAdapter()->getPathPrefix().$cachePath); - } catch (\Exception $e) { - $size = [0, 0]; - } finally { - $cache->delete($cachePath); - } - - return $size ? array_splice($size, 0, 2) : [0, 0]; - } - - /** - * Get the dimensions of an SVG. - * - * @return array - */ - private function getSvgDimensions() - { - // Since assets may be located on external platforms like Amazon S3, we can't simply - // grab the dimensions. So we'll copy it locally and read the dimensions from there. - $manager = new MountManager([ - 'source' => $this->asset->disk()->filesystem()->getDriver(), - 'cache' => $cache = $this->getCacheFlysystem(), - ]); - - $cachePath = "{$this->asset->containerId()}/{$this->asset->path()}"; - - if ($manager->has($destination = "cache://{$cachePath}")) { - $manager->delete($destination); - } - - $manager->copy("source://{$this->asset->path()}", $destination); - - $svg = simplexml_load_file($cache->getAdapter()->getPathPrefix().$cachePath); - - $cache->delete($cachePath); - - if ($svg['width'] && $svg['height'] - && is_numeric((string) $svg['width']) - && is_numeric((string) $svg['height'])) { - return [(float) $svg['width'], (float) $svg['height']]; - } elseif ($svg['viewBox']) { - $viewBox = preg_split('/[\s,]+/', $svg['viewBox'] ?: ''); - - return [$viewBox[2], $viewBox[3]]; - } - - return [300, 150]; - } - - private function getCacheFlysystem() - { - $disk = 'dimensions-cache'; - - config(["filesystems.disks.{$disk}" => [ - 'driver' => 'local', - 'root' => storage_path('statamic/dimensions-cache'), - ]]); - - return Storage::disk($disk)->getDriver(); - } } diff --git a/src/Assets/ExtractInfo.php b/src/Assets/ExtractInfo.php new file mode 100644 index 00000000000..478a2c7d749 --- /dev/null +++ b/src/Assets/ExtractInfo.php @@ -0,0 +1,16 @@ +disk()->filesystem(); + $path = $asset->path(); + + return (new \getID3)->analyze($path, $disk->getSize($path), '', $disk->readStream($path)); + } +} diff --git a/tests/Assets/AssetRepositoryTest.php b/tests/Assets/AssetRepositoryTest.php index f0c92543a39..7daa92cea0e 100644 --- a/tests/Assets/AssetRepositoryTest.php +++ b/tests/Assets/AssetRepositoryTest.php @@ -39,6 +39,7 @@ public function it_saves_the_meta_file_to_disk() width: 30 height: 60 mime_type: image/jpeg +duration: null EOT; $this->assertEquals($contents, $disk->get($path)); diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index c1381edba24..0e33a821d2a 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -42,7 +42,7 @@ public function setUp(): void ->disk('test'); Storage::fake('test'); - Storage::fake('dimensions-cache'); + Storage::fake('attributes-cache'); } /** @test */ @@ -377,6 +377,7 @@ public function it_generates_meta_on_demand_if_it_doesnt_exist() 'width' => 30, 'height' => 60, 'mime_type' => 'image/jpeg', + 'duration' => null, ]; $metaWithData = [ @@ -386,6 +387,7 @@ public function it_generates_meta_on_demand_if_it_doesnt_exist() 'width' => 30, 'height' => 60, 'mime_type' => 'image/jpeg', + 'duration' => null, ]; // The meta that's saved to file will also be cached, but will not include in-memory data... @@ -430,6 +432,7 @@ public function it_generates_meta_on_demand_if_a_required_value_is_missing() 'width' => 30, 'height' => 60, 'mime_type' => 'image/jpeg', + 'duration' => null, ]; Storage::disk('test')->put('foo/.meta/image.jpg.yaml', YAML::dump($incompleteMeta)); diff --git a/tests/Assets/AttributesTest.php b/tests/Assets/AttributesTest.php new file mode 100644 index 00000000000..8666c3ca431 --- /dev/null +++ b/tests/Assets/AttributesTest.php @@ -0,0 +1,152 @@ + [ + 'driver' => 'local', + 'root' => __DIR__.'/doesnt-matter-itll-get-faked-anyway', + ]]); + + Storage::fake('test'); + + $this->attributes = app(Attributes::class); + } + + /** @test */ + public function a_non_image_asset_has_no_attributes() + { + $asset = $this->mock(Asset::class); + $asset->shouldReceive('isAudio')->andReturnFalse(); + $asset->shouldReceive('isImage')->andReturnFalse(); + $asset->shouldReceive('isSvg')->andReturnFalse(); + $asset->shouldReceive('isVideo')->andReturnFalse(); + + $attributes = $this->attributes->asset($asset); + + $this->assertEquals([], $attributes->get()); + } + + /** @test */ + public function it_gets_the_attributes() + { + Carbon::setTestNow(now()); + + $asset = (new Asset) + ->container(AssetContainer::make('test-container')->disk('test')) + ->path('path/to/asset.jpg'); + + $file = UploadedFile::fake()->image('asset.jpg', 30, 60); + Storage::disk('test')->putFileAs('path/to', $file, 'asset.jpg'); + + // Test about the actual file, for good measure. + $realpath = Storage::disk('test')->getAdapter()->getPathPrefix().'path/to/asset.jpg'; + $this->assertFileExists($realpath); + [$width, $height] = getimagesize($realpath); + $this->assertEquals(30, $width); + $this->assertEquals(60, $height); + + $attributes = $this->attributes->asset($asset); + + $this->assertEquals(['width' => 30, 'height' => 60], $attributes->get()); + } + + /** @test */ + public function it_gets_the_attributes_of_audio_file() + { + $asset = (new Asset) + ->container(AssetContainer::make('test-container')->disk('test')) + ->path('path/to/asset.mp3'); + + ExtractInfo::shouldReceive('fromAsset')->with($asset)->andReturn(['playtime_seconds' => 13]); + + $attributes = $this->attributes->asset($asset); + + $this->assertEquals(['duration' => 13], $attributes->get()); + } + + /** @test */ + public function it_gets_the_attributes_of_video_file() + { + $asset = (new Asset) + ->container(AssetContainer::make('test-container')->disk('test')) + ->path('path/to/asset.mp4'); + + ExtractInfo::shouldReceive('fromAsset')->with($asset)->andReturn([ + 'playtime_seconds' => 13, + 'video' => [ + 'resolution_x' => 1920, + 'resolution_y' => 1080, + ], + ]); + + $attributes = $this->attributes->asset($asset); + + $this->assertEquals(['duration' => 13, 'width' => 1920, 'height' => 1080], $attributes->get()); + } + + /** @test */ + public function it_gets_the_attributes_of_an_svg() + { + $asset = $this->svgAsset(''); + + $this->assertEquals(['width' => 30.0, 'height' => 60.0], $this->attributes->asset($asset)->get()); + } + + /** @test */ + public function it_uses_the_viewbox_if_the_svg_dimensions_havent_been_provided() + { + $asset = $this->svgAsset(''); + + $this->assertEquals(['width' => 300, 'height' => 600], $this->attributes->asset($asset)->get()); + } + + /** @test */ + public function it_uses_the_viewbox_if_the_svg_dimensions_are_percents() + { + $asset = $this->svgAsset(''); + + $this->assertEquals(['width' => 300, 'height' => 600], $this->attributes->asset($asset)->get()); + } + + /** @test */ + public function it_uses_the_viewbox_if_the_svg_dimensions_are_ems() + { + $asset = $this->svgAsset(''); + + $this->assertEquals(['width' => 300, 'height' => 600], $this->attributes->asset($asset)->get()); + } + + /** @test */ + public function it_uses_default_attributes_if_the_svg_has_no_viewbox_and_is_missing_either_or_both_dimensions() + { + $this->assertEquals(['width' => 300, 'height' => 150], $this->attributes->asset($this->svgAsset(''))->get()); + $this->assertEquals(['width' => 300, 'height' => 150], $this->attributes->asset($this->svgAsset(''))->get()); + $this->assertEquals(['width' => 300, 'height' => 150], $this->attributes->asset($this->svgAsset(''))->get()); + } + + private function svgAsset($svg) + { + $asset = (new Asset) + ->container(AssetContainer::make('test-container')->disk('test')) + ->path('path/to/asset.svg'); + + Storage::disk('test')->put('path/to/asset.svg', $svg); + + return $asset; + } +} diff --git a/tests/Assets/DimensionsTest.php b/tests/Assets/DimensionsTest.php index 75ad24f0b44..6d1adfe68af 100644 --- a/tests/Assets/DimensionsTest.php +++ b/tests/Assets/DimensionsTest.php @@ -2,118 +2,120 @@ namespace Tests\Assets; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Storage; -use Statamic\Assets\Asset; -use Statamic\Assets\Dimensions; -use Statamic\Facades\AssetContainer; -use Statamic\Imaging\ImageGenerator; -use Tests\TestCase; - -class DimensionsTest extends TestCase -{ - public function setUp(): void - { - parent::setUp(); - - config(['filesystems.disks.test' => [ - 'driver' => 'local', - 'root' => __DIR__.'/doesnt-matter-itll-get-faked-anyway', - ]]); - - Storage::fake('test'); - - $this->dimensions = new Dimensions(app(ImageGenerator::class)); - } - - /** @test */ - public function a_non_image_asset_has_no_dimensions() - { - $asset = $this->mock(Asset::class); - $asset->shouldReceive('isImage')->andReturnFalse(); - $asset->shouldReceive('isSvg')->andReturnFalse(); - - $dimensions = $this->dimensions->asset($asset); - - $this->assertEquals([null, null], $dimensions->get()); - $this->assertEquals(null, $dimensions->width()); - $this->assertEquals(null, $dimensions->height()); - } - - /** @test */ - public function it_gets_the_dimensions() - { - Carbon::setTestNow(now()); - - $asset = (new Asset) - ->container(AssetContainer::make('test-container')->disk('test')) - ->path('path/to/asset.jpg'); - - $file = UploadedFile::fake()->image('asset.jpg', 30, 60); - Storage::disk('test')->putFileAs('path/to', $file, 'asset.jpg'); - - // Test about the actual file, for good measure. - $realpath = Storage::disk('test')->getAdapter()->getPathPrefix().'path/to/asset.jpg'; - $this->assertFileExists($realpath); - $imagesize = getimagesize($realpath); - $this->assertEquals([30, 60], array_splice($imagesize, 0, 2)); - - $dimensions = $this->dimensions->asset($asset); - - $this->assertEquals([30, 60], $dimensions->get()); - $this->assertEquals(30, $dimensions->width()); - $this->assertEquals(60, $dimensions->height()); - } - - /** @test */ - public function it_gets_the_dimensions_of_an_svg() - { - $asset = $this->svgAsset(''); - - $this->assertEquals([30, 60], $this->dimensions->asset($asset)->get()); - } - - /** @test */ - public function it_uses_the_viewbox_if_the_svg_dimensions_havent_been_provided() - { - $asset = $this->svgAsset(''); - - $this->assertEquals([300, 600], $this->dimensions->asset($asset)->get()); - } - - /** @test */ - public function it_uses_the_viewbox_if_the_svg_dimensions_are_percents() - { - $asset = $this->svgAsset(''); - - $this->assertEquals([300, 600], $this->dimensions->asset($asset)->get()); - } - - /** @test */ - public function it_uses_the_viewbox_if_the_svg_dimensions_are_ems() - { - $asset = $this->svgAsset(''); - - $this->assertEquals([300, 600], $this->dimensions->asset($asset)->get()); - } - - /** @test */ - public function it_uses_default_dimensions_if_the_svg_has_no_viewbox_and_is_missing_either_or_both_dimensions() - { - $this->assertEquals([300, 150], $this->dimensions->asset($this->svgAsset(''))->get()); - $this->assertEquals([300, 150], $this->dimensions->asset($this->svgAsset(''))->get()); - $this->assertEquals([300, 150], $this->dimensions->asset($this->svgAsset(''))->get()); - } - - private function svgAsset($svg) - { - $asset = (new Asset) - ->container(AssetContainer::make('test-container')->disk('test')) - ->path('path/to/asset.svg'); - - Storage::disk('test')->put('path/to/asset.svg', $svg); - - return $asset; - } -} + use Illuminate\Http\UploadedFile; + use Illuminate\Support\Carbon; + use Illuminate\Support\Facades\Storage; + use Statamic\Assets\Asset; + use Statamic\Assets\Dimensions; + use Statamic\Facades\AssetContainer; + use Statamic\Imaging\ImageGenerator; + use Tests\TestCase; + + class DimensionsTest extends TestCase + { + public function setUp(): void + { + parent::setUp(); + + config(['filesystems.disks.test' => [ + 'driver' => 'local', + 'root' => __DIR__.'/doesnt-matter-itll-get-faked-anyway', + ]]); + + Storage::fake('test'); + + $this->dimensions = new Dimensions(app(ImageGenerator::class)); + } + + /** @test */ + public function a_non_image_asset_has_no_dimensions() + { + $asset = $this->mock(Asset::class); + $asset->shouldReceive('isImage')->andReturnFalse(); + $asset->shouldReceive('isSvg')->andReturnFalse(); + $asset->shouldReceive('isAudio')->andReturnFalse(); + $asset->shouldReceive('isVideo')->andReturnFalse(); + + $dimensions = $this->dimensions->asset($asset); + + $this->assertEquals([null, null], $dimensions->get()); + $this->assertEquals(null, $dimensions->width()); + $this->assertEquals(null, $dimensions->height()); + } + + /** @test */ + public function it_gets_the_dimensions() + { + Carbon::setTestNow(now()); + + $asset = (new Asset) + ->container(AssetContainer::make('test-container')->disk('test')) + ->path('path/to/asset.jpg'); + + $file = UploadedFile::fake()->image('asset.jpg', 30, 60); + Storage::disk('test')->putFileAs('path/to', $file, 'asset.jpg'); + + // Test about the actual file, for good measure. + $realpath = Storage::disk('test')->getAdapter()->getPathPrefix().'path/to/asset.jpg'; + $this->assertFileExists($realpath); + $imagesize = getimagesize($realpath); + $this->assertEquals([30, 60], array_splice($imagesize, 0, 2)); + + $dimensions = $this->dimensions->asset($asset); + + $this->assertEquals([30, 60], $dimensions->get()); + $this->assertEquals(30, $dimensions->width()); + $this->assertEquals(60, $dimensions->height()); + } + + /** @test */ + public function it_gets_the_dimensions_of_an_svg() + { + $asset = $this->svgAsset(''); + + $this->assertEquals([30, 60], $this->dimensions->asset($asset)->get()); + } + + /** @test */ + public function it_uses_the_viewbox_if_the_svg_dimensions_havent_been_provided() + { + $asset = $this->svgAsset(''); + + $this->assertEquals([300, 600], $this->dimensions->asset($asset)->get()); + } + + /** @test */ + public function it_uses_the_viewbox_if_the_svg_dimensions_are_percents() + { + $asset = $this->svgAsset(''); + + $this->assertEquals([300, 600], $this->dimensions->asset($asset)->get()); + } + + /** @test */ + public function it_uses_the_viewbox_if_the_svg_dimensions_are_ems() + { + $asset = $this->svgAsset(''); + + $this->assertEquals([300, 600], $this->dimensions->asset($asset)->get()); + } + + /** @test */ + public function it_uses_default_dimensions_if_the_svg_has_no_viewbox_and_is_missing_either_or_both_dimensions() + { + $this->assertEquals([300, 150], $this->dimensions->asset($this->svgAsset(''))->get()); + $this->assertEquals([300, 150], $this->dimensions->asset($this->svgAsset(''))->get()); + $this->assertEquals([300, 150], $this->dimensions->asset($this->svgAsset(''))->get()); + } + + private function svgAsset($svg) + { + $asset = (new Asset) + ->container(AssetContainer::make('test-container')->disk('test')) + ->path('path/to/asset.svg'); + + Storage::disk('test')->put('path/to/asset.svg', $svg); + + return $asset; + } + }