Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ThumbnailService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('s3');
$suffix = Str::lower(Str::random(10));
$this->localOriginalsRoot = storage_path('framework/testing/square-command-originals-' . $suffix);
File::deleteDirectory($this->localOriginalsRoot);
File::ensureDirectoryExists($this->localOriginalsRoot);
config()->set('uploads.local_originals_root', $this->localOriginalsRoot);
config()->set('uploads.object_storage.disk', 's3');
config()->set('uploads.object_storage.prefix', 'artworks');
});
afterEach(function () {
File::deleteDirectory($this->localOriginalsRoot);
});
function generateSqCommandImage(string $root, string $filename = 'source.jpg'): string
{
$path = $root . DIRECTORY_SEPARATOR . $filename;
$image = imagecreatetruecolor(1200, 800);
$background = imagecolorallocate($image, 18, 22, 29);
$subject = imagecolorallocate($image, 245, 180, 110);
imagefilledrectangle($image, 0, 0, 1200, 800, $background);
imagefilledellipse($image, 280, 340, 360, 380, $subject);
imagejpeg($image, $path, 90);
imagedestroy($image);
return $path;
}
function generateSqCommandWebp(string $root, string $filename = 'source.webp'): string
{
$path = $root . DIRECTORY_SEPARATOR . $filename;
$image = imagecreatetruecolor(1400, 900);
$background = imagecolorallocate($image, 16, 21, 28);
$subject = imagecolorallocate($image, 242, 194, 94);
imagefilledrectangle($image, 0, 0, 1400, 900, $background);
imagefilledellipse($image, 360, 420, 420, 360, $subject);
imagewebp($image, $path, 90);
imagedestroy($image);
return $path;
}
function seedArtworkWithOriginal(string $localOriginalsRoot): Artwork
{
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$source = generateSqCommandImage($localOriginalsRoot, 'seed.jpg');
$hash = hash_file('sha256', $source);
$storage = app(UploadStorageService::class);
$localTarget = $storage->localOriginalPath($hash, $hash . '.jpg');
File::ensureDirectoryExists(dirname($localTarget));
File::copy($source, $localTarget);
$artwork->update([
'hash' => $hash,
'file_ext' => 'jpg',
'thumb_ext' => 'webp',
'width' => 1200,
'height' => 800,
]);
DB::table('artwork_files')->insert([
'artwork_id' => $artwork->id,
'variant' => 'orig_image',
'path' => "artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg",
'mime' => 'image/jpeg',
'size' => (int) filesize($localTarget),
]);
return $artwork->fresh();
}
it('generates missing square thumbnails from the best available source', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs');
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue();
$sqSize = getimagesizefromstring(Storage::disk('s3')->get($sqPath));
expect($sqSize[0] ?? null)->toBe(512)
->and($sqSize[1] ?? null)->toBe(512);
});
it('supports dry run without writing sq thumbnails', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--dry-run' => true]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('planned=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertMissing($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeFalse();
});
it('forces regeneration when an sq row already exists', function () {
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
DB::table('artwork_files')->insert([
'artwork_id' => $artwork->id,
'variant' => 'sq',
'path' => "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp",
'mime' => 'image/webp',
'size' => 0,
]);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--force' => true]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
});
it('purges the canonical sq url after regeneration when Cloudflare is configured', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', 'test-zone');
config()->set('cdn.cloudflare.api_token', 'test-token');
$artwork = seedArtworkWithOriginal($this->localOriginalsRoot);
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]);
expect($code)->toBe(0);
Http::assertSent(function ($request) use ($artwork): bool {
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
&& $request['files'] === [
'https://cdn.skinbase.org/artworks/sq/' . substr($artwork->hash, 0, 2) . '/' . substr($artwork->hash, 2, 2) . '/' . $artwork->hash . '.webp',
];
});
});
it('falls back to canonical CDN derivatives for legacy artworks without artwork_files rows', function () {
$cdnRoot = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . 'fake-cdn';
File::ensureDirectoryExists($cdnRoot);
config()->set('cdn.files_url', 'file:///' . str_replace(DIRECTORY_SEPARATOR, '/', $cdnRoot));
$user = User::factory()->create();
$hash = '6183c98975512ee6bff4657043067953a33769c7';
$artwork = Artwork::factory()->for($user)->unpublished()->create([
'hash' => $hash,
'file_ext' => 'jpg',
'thumb_ext' => 'webp',
'file_path' => 'legacy/uploads/IMG_20210727_090534.jpg',
'width' => 4624,
'height' => 2080,
]);
$xlUrl = ThumbnailService::fromHash($hash, 'webp', 'xl');
$webpSource = generateSqCommandWebp($this->localOriginalsRoot, 'legacy-xl.webp');
$xlPath = $cdnRoot . DIRECTORY_SEPARATOR . 'artworks' . DIRECTORY_SEPARATOR . 'xl' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.webp';
File::ensureDirectoryExists(dirname($xlPath));
File::copy($webpSource, $xlPath);
expect($xlUrl)->toContain('/artworks/xl/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash . '.webp');
$code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('generated=1');
$sqPath = "artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp";
Storage::disk('s3')->assertExists($sqPath);
expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue();
});