Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -31,14 +31,24 @@ final class StudioArtworkAiAssistApiController extends Controller
public function analyze(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
$direct = (bool) $request->boolean('direct');
$intent = $request->validate([
$payload = $request->validate([
'direct' => ['sometimes', 'boolean'],
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
])['intent'] ?? null;
'provider' => ['sometimes', 'nullable', 'string'],
]);
$direct = (bool) ($payload['direct'] ?? false);
$intent = $payload['intent'] ?? null;
$provider = $this->normalizeProviderOption($payload['provider'] ?? null);
if ($provider === null && array_key_exists('provider', $payload) && $payload['provider'] !== null) {
return response()->json([
'success' => false,
'message' => 'Invalid provider. Supported values: lm_studio, together.',
], 422);
}
if ($direct) {
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent);
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent, $provider);
return response()->json([
'success' => true,
@@ -48,7 +58,7 @@ final class StudioArtworkAiAssistApiController extends Controller
]);
}
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent);
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent, $provider);
return response()->json([
'success' => true,
@@ -60,14 +70,24 @@ final class StudioArtworkAiAssistApiController extends Controller
public function regenerate(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
$direct = (bool) $request->boolean('direct');
$intent = $request->validate([
$payload = $request->validate([
'direct' => ['sometimes', 'boolean'],
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
])['intent'] ?? null;
'provider' => ['sometimes', 'nullable', 'string'],
]);
$direct = (bool) ($payload['direct'] ?? false);
$intent = $payload['intent'] ?? null;
$provider = $this->normalizeProviderOption($payload['provider'] ?? null);
if ($provider === null && array_key_exists('provider', $payload) && $payload['provider'] !== null) {
return response()->json([
'success' => false,
'message' => 'Invalid provider. Supported values: lm_studio, together.',
], 422);
}
if ($direct) {
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent);
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent, $provider);
return response()->json([
'success' => true,
@@ -77,7 +97,7 @@ final class StudioArtworkAiAssistApiController extends Controller
]);
}
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent);
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent, $provider);
return response()->json([
'success' => true,
@@ -114,4 +134,17 @@ final class StudioArtworkAiAssistApiController extends Controller
return response()->json(['success' => true], 201);
}
private function normalizeProviderOption(mixed $value): ?string
{
if ($value === null || trim((string) $value) === '') {
return null;
}
return match (strtolower(trim((string) $value))) {
'lm_studio', 'lm-studio', 'local', 'home' => 'lm_studio',
'together', 'together_ai' => 'together',
default => null,
};
}
}

View File

@@ -19,6 +19,7 @@ use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use App\Services\Worlds\WorldSubmissionService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -125,7 +126,7 @@ final class StudioArtworksApiController extends Controller
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$evolution = app(ArtworkEvolutionService::class);
@@ -154,6 +155,9 @@ final class StudioArtworksApiController extends Controller
'contributor_credits.*.user_id' => 'required|integer|min:1',
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
'contributor_credits.*.is_primary' => 'nullable|boolean',
'world_submissions' => 'sometimes|array|max:12',
'world_submissions.*.world_id' => 'required|integer|exists:worlds,id',
'world_submissions.*.note' => 'nullable|string|max:1000',
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
'evolution_note' => 'sometimes|nullable|string|max:1200',
@@ -166,6 +170,7 @@ final class StudioArtworksApiController extends Controller
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|| array_key_exists('evolution_relation_type', $validated)
|| array_key_exists('evolution_note', $validated);
$worldSubmissionPayload = $validated['world_submissions'] ?? null;
$attributionPayload = [
'group' => $validated['group'] ?? $artwork->group?->slug,
@@ -208,7 +213,7 @@ final class StudioArtworksApiController extends Controller
'relation_type' => $validated['evolution_relation_type'] ?? null,
'note' => $validated['evolution_note'] ?? null,
];
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits']);
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
$validated['visibility'] = $visibility;
@@ -271,6 +276,14 @@ final class StudioArtworksApiController extends Controller
}
}
if ($worldSubmissionPayload !== null) {
try {
$submissions->syncForArtwork($artwork->fresh(), $request->user(), (array) $worldSubmissionPayload);
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
}
// Reindex in Meilisearch
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
@@ -316,6 +329,7 @@ final class StudioArtworksApiController extends Controller
'category_source' => $artwork->category_source ?: 'manual',
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
],
'world_submission_options' => $submissions->artworkSubmissionOptions($artwork->fresh(['worldSubmissions.world', 'worldSubmissions.reviewer']), $request->user()),
]);
}

View File

@@ -25,6 +25,7 @@ use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -493,6 +494,7 @@ final class StudioController extends Controller
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,
],
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
'contentTypes' => $this->getCategories(),
'groupOptions' => $availableGroups,
'contributorOptionsByGroup' => $contributorOptionsByGroup,

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Http\Requests\Worlds\StoreWorldRequest;
use App\Http\Requests\Worlds\UpdateWorldRequest;
use App\Models\World;
use App\Models\WorldSubmission;
use App\Services\Worlds\WorldService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StudioWorldController extends Controller
{
public function __construct(
private readonly WorldService $worlds,
private readonly WorldSubmissionService $submissions,
)
{
}
public function index(Request $request): Response
{
$this->authorize('manage', World::class);
return Inertia::render('Studio/StudioWorldsIndex', [
'title' => 'Worlds',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
'statusOptions' => [
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
],
'typeOptions' => [
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
['value' => World::TYPE_EVENT, 'label' => 'Event'],
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
],
'createUrl' => route('studio.worlds.create'),
]);
}
public function create(Request $request): Response|RedirectResponse
{
if (! $request->user()?->can('create', World::class)) {
return redirect()->route('worlds.index');
}
return Inertia::render('Studio/StudioWorldEditor', [
'title' => 'Create world',
'description' => 'Build a curated campaign destination with themed visuals, ordered sections, and explicit content attachments.',
'world' => null,
'themeOptions' => $this->worlds->themeOptions(),
'sectionOptions' => $this->worlds->sectionOptions(),
'relationTypeOptions' => $this->worlds->relationTypeOptions(),
'typeOptions' => [
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
['value' => World::TYPE_EVENT, 'label' => 'Event'],
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
],
'statusOptions' => [
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
],
'storeUrl' => route('studio.worlds.store'),
'entitySearchUrl' => route('studio.worlds.entity-search'),
'duplicateActions' => null,
'mediaSupport' => [
'picker_available' => false,
'helper_text' => 'Drop a cover or OG image here and Skinbase will optimize it and store it on the CDN automatically.',
'upload_url' => route('api.studio.worlds.media.upload'),
'delete_url' => route('api.studio.worlds.media.destroy'),
'files_base_url' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/webp'],
'max_file_size_mb' => 6,
],
]);
}
public function store(StoreWorldRequest $request): RedirectResponse
{
$this->authorize('create', World::class);
$world = $this->worlds->store($request->user(), $request->validated());
return redirect()->route('studio.worlds.edit', ['world' => $world])->with('success', 'World draft created.');
}
public function edit(Request $request, World $world): Response
{
$this->authorize('update', $world);
return Inertia::render('Studio/StudioWorldEditor', [
'title' => 'Edit world',
'description' => 'Tune the world identity, adjust section order, and refine the curated attachments.',
'world' => $this->worlds->mapStudioWorld($world, $request->user()),
'themeOptions' => $this->worlds->themeOptions(),
'sectionOptions' => $this->worlds->sectionOptions(),
'relationTypeOptions' => $this->worlds->relationTypeOptions(),
'typeOptions' => [
['value' => World::TYPE_SEASONAL, 'label' => 'Seasonal'],
['value' => World::TYPE_EVENT, 'label' => 'Event'],
['value' => World::TYPE_CAMPAIGN, 'label' => 'Campaign'],
['value' => World::TYPE_TRIBUTE, 'label' => 'Tribute'],
],
'statusOptions' => [
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
['value' => World::STATUS_ARCHIVED, 'label' => 'Archived'],
],
'updateUrl' => route('studio.worlds.update', ['world' => $world]),
'previewUrl' => route('studio.worlds.preview', ['world' => $world]),
'publishUrl' => route('studio.worlds.publish', ['world' => $world]),
'archiveUrl' => route('studio.worlds.archive', ['world' => $world]),
'entitySearchUrl' => route('studio.worlds.entity-search'),
'duplicateActions' => [
'duplicateUrl' => route('studio.worlds.duplicate', ['world' => $world]),
'newEditionUrl' => route('studio.worlds.new-edition', ['world' => $world]),
'canCreateEdition' => $this->worlds->canCreateNewEdition($world),
],
'mediaSupport' => [
'picker_available' => false,
'helper_text' => 'Drop a cover or OG image here and Skinbase will optimize it and store it on the CDN automatically.',
'upload_url' => route('api.studio.worlds.media.upload'),
'delete_url' => route('api.studio.worlds.media.destroy'),
'files_base_url' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/webp'],
'max_file_size_mb' => 6,
],
]);
}
public function update(UpdateWorldRequest $request, World $world): RedirectResponse
{
$this->authorize('update', $world);
$this->worlds->update($world, $request->user(), $request->validated());
return back()->with('success', 'World updated.');
}
public function publish(Request $request, World $world): RedirectResponse
{
$this->authorize('update', $world);
$this->worlds->publish($world);
return back()->with('success', 'World published.');
}
public function archive(Request $request, World $world): RedirectResponse
{
$this->authorize('update', $world);
$this->worlds->archive($world);
return back()->with('success', 'World archived.');
}
public function duplicate(Request $request, World $world): RedirectResponse
{
$this->authorize('create', World::class);
$this->authorize('update', $world);
$duplicate = $this->worlds->duplicate($world, $request->user(), false);
return redirect()->route('studio.worlds.edit', ['world' => $duplicate])->with('success', 'World duplicated into a new draft.');
}
public function newEdition(Request $request, World $world): RedirectResponse
{
$this->authorize('create', World::class);
$this->authorize('update', $world);
$edition = $this->worlds->duplicate($world, $request->user(), true);
return redirect()->route('studio.worlds.edit', ['world' => $edition])->with('success', 'Next edition draft created.');
}
public function entitySearch(Request $request): JsonResponse
{
$this->authorize('manage', World::class);
$validated = $request->validate([
'type' => ['required', 'string'],
'q' => ['nullable', 'string', 'max:120'],
]);
return response()->json([
'items' => $this->worlds->searchEntities((string) $validated['type'], (string) ($validated['q'] ?? ''), $request->user()),
]);
}
public function approveSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission approved and is now live.');
}
public function removeSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_REMOVED, 'Submission removed from the world.');
}
public function blockSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_BLOCKED, 'Submission blocked from this world.');
}
public function unblockSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_REMOVED, 'Submission unblocked. It can now be restored or re-added later.');
}
public function restoreSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission restored to live.');
}
public function featureSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->toggleFeaturedSubmission($request, $world, $submission, true, 'Submission featured in the public community section.');
}
public function unfeatureSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->toggleFeaturedSubmission($request, $world, $submission, false, 'Submission removed from featured community placement.');
}
public function pendingSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_PENDING, 'Submission returned to pending.');
}
public function preview(Request $request, World $world): \Inertia\Response
{
$this->authorize('update', $world);
$payload = $this->worlds->publicShowPayload($world, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
route('studio.worlds.preview', ['world' => $world]),
$world->ogImageUrl(),
false,
)->toArray();
return Inertia::render('World/WorldShow', array_merge($payload, [
'seo' => $seo,
'previewMode' => true,
]))->rootView('collections');
}
private function transitionSubmission(Request $request, World $world, WorldSubmission $submission, string $status, string $flashMessage): RedirectResponse
{
$this->authorize('update', $world);
abort_unless((int) $submission->world_id === (int) $world->id, 404);
$validated = $request->validate([
'review_note' => ['nullable', 'string', 'max:1000'],
]);
$this->submissions->transition($submission, $request->user(), $status, $validated['review_note'] ?? null);
return back()->with('success', $flashMessage);
}
private function toggleFeaturedSubmission(Request $request, World $world, WorldSubmission $submission, bool $featured, string $flashMessage): RedirectResponse
{
$this->authorize('update', $world);
abort_unless((int) $submission->world_id === (int) $world->id, 404);
$validated = $request->validate([
'review_note' => ['nullable', 'string', 'max:1000'],
]);
$this->submissions->setFeatured($submission, $request->user(), $featured, $validated['review_note'] ?? null);
return back()->with('success', $flashMessage);
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\World;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class StudioWorldMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const SLOT_CONFIG = [
'cover' => [
'max_width' => 2200,
'max_height' => 1400,
'min_width' => 1200,
'min_height' => 630,
],
'og' => [
'max_width' => 1600,
'max_height' => 1000,
'min_width' => 1200,
'min_height' => 630,
],
];
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'slot' => ['required', 'string', 'in:cover,og'],
'image' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
'world_id' => ['nullable', 'integer', 'exists:worlds,id'],
]);
$world = isset($validated['world_id']) ? World::query()->findOrFail((int) $validated['world_id']) : null;
if ($world instanceof World) {
$this->authorize('update', $world);
} else {
$this->authorize('create', World::class);
}
/** @var UploadedFile $file */
$file = $validated['image'];
$slot = (string) $validated['slot'];
try {
$stored = $this->storeMediaFile($file, $slot);
return response()->json([
'success' => true,
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'],
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('World media upload failed', [
'user_id' => (int) ($request->user()?->id ?? 0),
'world_id' => $world?->id,
'slot' => $slot,
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Upload failed',
'message' => 'Could not upload image right now.',
], 500);
}
}
public function destroy(Request $request): JsonResponse
{
$validated = $request->validate([
'path' => ['required', 'string', 'max:2048'],
'world_id' => ['nullable', 'integer', 'exists:worlds,id'],
]);
$world = isset($validated['world_id']) ? World::query()->findOrFail((int) $validated['world_id']) : null;
if ($world instanceof World) {
$this->authorize('update', $world);
} else {
$this->authorize('create', World::class);
}
$this->deleteMediaFile((string) $validated['path']);
return response()->json([
'success' => true,
]);
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$config = self::SLOT_CONFIG[$slot] ?? self::SLOT_CONFIG['cover'];
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < $config['min_width'] || $height < $config['min_height']) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
$config['min_width'],
$config['min_height'],
));
}
$image = $this->manager->read($raw)->scaleDown(width: $config['max_width'], height: $config['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($slot, $hash);
$disk = Storage::disk($this->mediaDiskName());
$written = $disk->put($path, $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
return [
'path' => $path,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
'size_bytes' => strlen($encoded),
];
}
private function mediaDiskName(): string
{
return (string) config('covers.disk', 's3');
}
private function mediaPath(string $slot, string $hash): string
{
return sprintf(
'worlds/media/%s/%s/%s/%s.webp',
$slot,
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, 'worlds/media/')) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->mediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production world media storage must use object storage, not local/public disks.');
}
}
}