619 lines
24 KiB
PHP
619 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Tenant;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Tenant\AiEditIndexRequest;
|
|
use App\Http\Requests\Tenant\AiEditStoreRequest;
|
|
use App\Jobs\ProcessAiEditRequest;
|
|
use App\Models\AiEditRequest;
|
|
use App\Models\AiProviderRun;
|
|
use App\Models\AiStyle;
|
|
use App\Models\AiUsageLedger;
|
|
use App\Models\Event;
|
|
use App\Models\Photo;
|
|
use App\Services\AiEditing\AiBudgetGuardService;
|
|
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
|
use App\Services\AiEditing\AiStyleAccessService;
|
|
use App\Services\AiEditing\AiStylingEntitlementService;
|
|
use App\Services\AiEditing\EventAiEditingPolicyService;
|
|
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
|
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
|
use App\Support\ApiError;
|
|
use App\Support\TenantMemberPermissions;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class AiEditController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly AiSafetyPolicyService $safetyPolicy,
|
|
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
|
private readonly AiBudgetGuardService $budgetGuard,
|
|
private readonly AiStylingEntitlementService $entitlements,
|
|
private readonly EventAiEditingPolicyService $eventPolicy,
|
|
private readonly AiStyleAccessService $styleAccess,
|
|
private readonly AiAbuseEscalationService $abuseEscalation,
|
|
) {}
|
|
|
|
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
|
|
{
|
|
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
|
|
$perPage = (int) $request->input('per_page', 20);
|
|
$status = (string) $request->input('status', '');
|
|
$safetyState = (string) $request->input('safety_state', '');
|
|
|
|
$query = AiEditRequest::query()
|
|
->with(['style', 'outputs'])
|
|
->where('event_id', $event->id)
|
|
->orderByDesc('created_at');
|
|
|
|
if ($status !== '') {
|
|
$query->where('status', $status);
|
|
}
|
|
|
|
if ($safetyState !== '') {
|
|
$query->where('safety_state', $safetyState);
|
|
}
|
|
|
|
$requests = $query->paginate($perPage);
|
|
|
|
return response()->json([
|
|
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
|
|
'meta' => [
|
|
'current_page' => $requests->currentPage(),
|
|
'per_page' => $requests->perPage(),
|
|
'total' => $requests->total(),
|
|
'last_page' => $requests->lastPage(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function styles(Request $request, string $eventSlug): JsonResponse
|
|
{
|
|
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
|
|
if (! $this->runtimeConfig->isEnabled()) {
|
|
return ApiError::response(
|
|
'feature_disabled',
|
|
'Feature disabled',
|
|
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
|
Response::HTTP_FORBIDDEN
|
|
);
|
|
}
|
|
|
|
$entitlement = $this->entitlements->resolveForEvent($event);
|
|
if (! $entitlement['allowed']) {
|
|
return ApiError::response(
|
|
'feature_locked',
|
|
'Feature locked',
|
|
$this->entitlements->lockedMessage(),
|
|
Response::HTTP_FORBIDDEN,
|
|
[
|
|
'required_feature' => $entitlement['required_feature'],
|
|
'addon_keys' => $entitlement['addon_keys'],
|
|
]
|
|
);
|
|
}
|
|
|
|
$styles = AiStyle::query()
|
|
->where('is_active', true)
|
|
->orderBy('sort')
|
|
->orderBy('id')
|
|
->get();
|
|
$policy = $this->eventPolicy->resolve($event);
|
|
$styles = $this->eventPolicy->filterStyles($event, $styles);
|
|
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
|
|
|
return response()->json([
|
|
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
|
'meta' => [
|
|
'required_feature' => $entitlement['required_feature'],
|
|
'addon_keys' => $entitlement['addon_keys'],
|
|
'event_enabled' => $policy['enabled'],
|
|
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
|
'allowed_style_keys' => $policy['allowed_style_keys'],
|
|
'policy_message' => $policy['policy_message'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function summary(Request $request, string $eventSlug): JsonResponse
|
|
{
|
|
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
|
|
$periodStart = now()->startOfMonth();
|
|
$periodEnd = now()->endOfMonth();
|
|
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
|
|
$statusCounts = (clone $baseQuery)
|
|
->select('status', DB::raw('count(*) as aggregate'))
|
|
->groupBy('status')
|
|
->pluck('aggregate', 'status')
|
|
->map(fn (mixed $value): int => (int) $value)
|
|
->all();
|
|
$safetyCounts = (clone $baseQuery)
|
|
->select('safety_state', DB::raw('count(*) as aggregate'))
|
|
->groupBy('safety_state')
|
|
->pluck('aggregate', 'safety_state')
|
|
->map(fn (mixed $value): int => (int) $value)
|
|
->all();
|
|
|
|
$lastRequestedAt = (clone $baseQuery)->max('created_at');
|
|
$total = array_sum($statusCounts);
|
|
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
|
|
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
|
|
|
|
$usageQuery = AiUsageLedger::query()
|
|
->where('tenant_id', $event->tenant_id)
|
|
->where('event_id', $event->id)
|
|
->where('recorded_at', '>=', $periodStart)
|
|
->where('recorded_at', '<=', $periodEnd);
|
|
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
|
|
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
|
|
|
|
$providerRunQuery = AiProviderRun::query()
|
|
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
|
|
->where('created_at', '>=', $periodStart)
|
|
->where('created_at', '<=', $periodEnd);
|
|
$providerRunTotal = (int) (clone $providerRunQuery)->count();
|
|
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
|
|
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
|
|
|
|
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
|
|
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
|
|
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
|
|
|
|
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
|
|
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
|
|
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
|
|
|
return response()->json([
|
|
'data' => [
|
|
'event_id' => $event->id,
|
|
'total' => $total,
|
|
'status_counts' => $statusCounts,
|
|
'safety_counts' => $safetyCounts,
|
|
'failed_total' => $failedTotal,
|
|
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
|
|
'usage' => [
|
|
'period_start' => $periodStart->toDateString(),
|
|
'period_end' => $periodEnd->toDateString(),
|
|
'debit_count' => $debitCount,
|
|
'spend_usd' => round($spendUsd, 5),
|
|
],
|
|
'observability' => [
|
|
'failure_rate' => round($failureRate, 5),
|
|
'moderation_hit_rate' => round($moderationHitRate, 5),
|
|
'provider_runs_total' => $providerRunTotal,
|
|
'provider_runs_failed' => $providerRunFailed,
|
|
'provider_failure_rate' => round($providerFailureRate, 5),
|
|
'avg_provider_latency_ms' => $averageProviderLatencyMs,
|
|
'alerts' => [
|
|
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
|
|
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
|
|
],
|
|
],
|
|
'budget' => $budgetDecision['budget'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
|
|
{
|
|
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
|
|
$photo = Photo::query()
|
|
->whereKey((int) $request->input('photo_id'))
|
|
->where('event_id', $event->id)
|
|
->first();
|
|
|
|
if (! $photo) {
|
|
return ApiError::response(
|
|
'photo_not_found',
|
|
'Photo not found',
|
|
'The specified photo could not be located for this event.',
|
|
Response::HTTP_NOT_FOUND
|
|
);
|
|
}
|
|
|
|
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
|
|
if (! $style) {
|
|
return ApiError::response(
|
|
'style_not_found',
|
|
'Style not found',
|
|
'The selected style is not available.',
|
|
Response::HTTP_UNPROCESSABLE_ENTITY
|
|
);
|
|
}
|
|
|
|
if (! $this->runtimeConfig->isEnabled()) {
|
|
return ApiError::response(
|
|
'feature_disabled',
|
|
'Feature disabled',
|
|
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
|
Response::HTTP_FORBIDDEN
|
|
);
|
|
}
|
|
|
|
$entitlement = $this->entitlements->resolveForEvent($event);
|
|
if (! $entitlement['allowed']) {
|
|
return ApiError::response(
|
|
'feature_locked',
|
|
'Feature locked',
|
|
$this->entitlements->lockedMessage(),
|
|
Response::HTTP_FORBIDDEN,
|
|
[
|
|
'required_feature' => $entitlement['required_feature'],
|
|
'addon_keys' => $entitlement['addon_keys'],
|
|
]
|
|
);
|
|
}
|
|
|
|
$policy = $this->eventPolicy->resolve($event);
|
|
if (! $policy['enabled']) {
|
|
return ApiError::response(
|
|
'event_feature_disabled',
|
|
'Feature disabled for this event',
|
|
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
|
Response::HTTP_FORBIDDEN
|
|
);
|
|
}
|
|
|
|
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
|
if (! $budgetDecision['allowed']) {
|
|
return ApiError::response(
|
|
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
|
|
'Budget limit reached',
|
|
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
|
|
Response::HTTP_FORBIDDEN,
|
|
[
|
|
'budget' => $budgetDecision['budget'],
|
|
]
|
|
);
|
|
}
|
|
|
|
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
|
|
return ApiError::response(
|
|
'style_not_allowed',
|
|
'Style not allowed',
|
|
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
|
[
|
|
'allowed_style_keys' => $policy['allowed_style_keys'],
|
|
]
|
|
);
|
|
}
|
|
|
|
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
|
|
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
|
|
$providerModel = $request->input('provider_model') ?: $style->provider_model;
|
|
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
|
$requestedByUserId = $request->user()?->id;
|
|
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
|
|
$abuseSignal = null;
|
|
$safetyReasons = $safetyDecision->reasonCodes;
|
|
if ($safetyDecision->blocked) {
|
|
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
|
|
(int) $event->tenant_id,
|
|
(int) $event->id,
|
|
$scopeKey
|
|
);
|
|
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
|
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
|
}
|
|
}
|
|
|
|
$metadata = (array) $request->input('metadata', []);
|
|
if (is_array($abuseSignal)) {
|
|
$metadata['abuse'] = $abuseSignal;
|
|
}
|
|
$metadata['budget'] = $budgetDecision['budget'];
|
|
|
|
$idempotencyKey = $this->resolveIdempotencyKey(
|
|
$request->input('idempotency_key'),
|
|
$request->header('X-Idempotency-Key'),
|
|
$event,
|
|
$photo,
|
|
$style,
|
|
$prompt,
|
|
$requestedByUserId
|
|
);
|
|
|
|
$editRequest = AiEditRequest::query()->firstOrCreate(
|
|
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
|
[
|
|
'event_id' => $event->id,
|
|
'photo_id' => $photo->id,
|
|
'style_id' => $style->id,
|
|
'requested_by_user_id' => $requestedByUserId,
|
|
'provider' => $this->runtimeConfig->defaultProvider(),
|
|
'provider_model' => $providerModel,
|
|
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
|
'safety_state' => $safetyDecision->state,
|
|
'prompt' => $prompt,
|
|
'negative_prompt' => $negativePrompt,
|
|
'input_image_path' => $photo->file_path,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'safety_reasons' => $safetyReasons,
|
|
'failure_code' => $safetyDecision->failureCode,
|
|
'failure_message' => $safetyDecision->failureMessage,
|
|
'queued_at' => now(),
|
|
'completed_at' => $safetyDecision->blocked ? now() : null,
|
|
'metadata' => $metadata,
|
|
]
|
|
);
|
|
|
|
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
|
$editRequest,
|
|
$event,
|
|
$photo,
|
|
$style,
|
|
$prompt,
|
|
$negativePrompt,
|
|
$providerModel,
|
|
$requestedByUserId
|
|
)) {
|
|
return ApiError::response(
|
|
'idempotency_conflict',
|
|
'Idempotency conflict',
|
|
'The provided idempotency key is already in use for another request.',
|
|
Response::HTTP_CONFLICT
|
|
);
|
|
}
|
|
|
|
if (
|
|
$editRequest->wasRecentlyCreated
|
|
&& ! $safetyDecision->blocked
|
|
&& $this->runtimeConfig->queueAutoDispatch()
|
|
) {
|
|
ProcessAiEditRequest::dispatch($editRequest->id)
|
|
->onQueue($this->runtimeConfig->queueName());
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
|
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
|
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
|
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
|
}
|
|
|
|
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
|
|
{
|
|
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
|
|
$editRequest = AiEditRequest::query()
|
|
->with(['style', 'outputs'])
|
|
->whereKey($aiEditRequest)
|
|
->where('event_id', $event->id)
|
|
->first();
|
|
|
|
if (! $editRequest) {
|
|
return ApiError::response(
|
|
'edit_request_not_found',
|
|
'Edit request not found',
|
|
'The specified AI edit request could not be located for this event.',
|
|
Response::HTTP_NOT_FOUND
|
|
);
|
|
}
|
|
|
|
return response()->json([
|
|
'data' => $this->serializeRequest($editRequest),
|
|
]);
|
|
}
|
|
|
|
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
|
|
{
|
|
$tenantId = $request->attributes->get('tenant_id');
|
|
|
|
return Event::query()
|
|
->where('slug', $eventSlug)
|
|
->where('tenant_id', $tenantId)
|
|
->firstOrFail();
|
|
}
|
|
|
|
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
|
|
{
|
|
if ($styleId !== null) {
|
|
return AiStyle::query()
|
|
->whereKey((int) $styleId)
|
|
->where('is_active', true)
|
|
->first();
|
|
}
|
|
|
|
$key = trim((string) ($styleKey ?? ''));
|
|
if ($key === '') {
|
|
return null;
|
|
}
|
|
|
|
return AiStyle::query()
|
|
->where('key', $key)
|
|
->where('is_active', true)
|
|
->first();
|
|
}
|
|
|
|
private function resolveIdempotencyKey(
|
|
mixed $bodyKey,
|
|
mixed $headerKey,
|
|
Event $event,
|
|
Photo $photo,
|
|
AiStyle $style,
|
|
string $prompt,
|
|
mixed $requestedByUserId
|
|
): string {
|
|
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
|
|
if ($candidate !== '') {
|
|
return Str::limit($candidate, 120, '');
|
|
}
|
|
|
|
return substr(hash('sha256', implode('|', [
|
|
(string) $event->id,
|
|
(string) $photo->id,
|
|
(string) $style->id,
|
|
trim($prompt),
|
|
(string) ($this->normalizeUserId($requestedByUserId)),
|
|
])), 0, 120);
|
|
}
|
|
|
|
private function normalizeUserId(mixed $userId): ?string
|
|
{
|
|
if (! is_int($userId) && ! is_string($userId)) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim((string) $userId);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
private function normalizeOptionalString(?string $value): ?string
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
$trimmed = trim($value);
|
|
|
|
return $trimmed !== '' ? $trimmed : null;
|
|
}
|
|
|
|
private function isIdempotencyConflict(
|
|
AiEditRequest $request,
|
|
Event $event,
|
|
Photo $photo,
|
|
AiStyle $style,
|
|
string $prompt,
|
|
string $negativePrompt,
|
|
?string $providerModel,
|
|
mixed $requestedByUserId
|
|
): bool {
|
|
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
|
return true;
|
|
}
|
|
|
|
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
|
return true;
|
|
}
|
|
|
|
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
|
|
}
|
|
|
|
private function serializeStyle(AiStyle $style): array
|
|
{
|
|
return [
|
|
'id' => $style->id,
|
|
'key' => $style->key,
|
|
'name' => $style->name,
|
|
'version' => $style->version,
|
|
'category' => $style->category,
|
|
'description' => $style->description,
|
|
'provider' => $style->provider,
|
|
'provider_model' => $style->provider_model,
|
|
'requires_source_image' => $style->requires_source_image,
|
|
'is_premium' => $style->is_premium,
|
|
'metadata' => $style->metadata ?? [],
|
|
];
|
|
}
|
|
|
|
private function serializeRequest(AiEditRequest $request): array
|
|
{
|
|
return [
|
|
'id' => $request->id,
|
|
'event_id' => $request->event_id,
|
|
'photo_id' => $request->photo_id,
|
|
'style' => $request->style ? [
|
|
'id' => $request->style->id,
|
|
'key' => $request->style->key,
|
|
'name' => $request->style->name,
|
|
] : null,
|
|
'provider' => $request->provider,
|
|
'provider_model' => $request->provider_model,
|
|
'status' => $request->status,
|
|
'safety_state' => $request->safety_state,
|
|
'safety_reasons' => $request->safety_reasons ?? [],
|
|
'failure_code' => $request->failure_code,
|
|
'failure_message' => $request->failure_message,
|
|
'queued_at' => $request->queued_at?->toIso8601String(),
|
|
'started_at' => $request->started_at?->toIso8601String(),
|
|
'completed_at' => $request->completed_at?->toIso8601String(),
|
|
'outputs' => $request->outputs->map(fn ($output) => [
|
|
'id' => $output->id,
|
|
'storage_disk' => $output->storage_disk,
|
|
'storage_path' => $output->storage_path,
|
|
'provider_url' => $output->provider_url,
|
|
'url' => $this->resolveOutputUrl(
|
|
$output->storage_disk,
|
|
$output->storage_path,
|
|
$output->provider_url
|
|
),
|
|
'mime_type' => $output->mime_type,
|
|
'width' => $output->width,
|
|
'height' => $output->height,
|
|
'is_primary' => $output->is_primary,
|
|
'safety_state' => $output->safety_state,
|
|
'safety_reasons' => $output->safety_reasons ?? [],
|
|
'generated_at' => $output->generated_at?->toIso8601String(),
|
|
])->values(),
|
|
];
|
|
}
|
|
|
|
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
|
|
{
|
|
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
|
|
if ($resolvedStoragePath !== null) {
|
|
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
|
|
return $resolvedStoragePath;
|
|
}
|
|
|
|
$disk = $this->resolveStorageDisk($storageDisk);
|
|
|
|
try {
|
|
return Storage::disk($disk)->url($resolvedStoragePath);
|
|
} catch (\Throwable $exception) {
|
|
Log::debug('Falling back to raw AI output storage path', [
|
|
'disk' => $disk,
|
|
'path' => $resolvedStoragePath,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
|
|
return '/'.ltrim($resolvedStoragePath, '/');
|
|
}
|
|
}
|
|
|
|
return $this->normalizeOptionalString($providerUrl);
|
|
}
|
|
|
|
private function resolveStorageDisk(?string $disk): string
|
|
{
|
|
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
|
|
|
|
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
|
|
return (string) config('filesystems.default', 'public');
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
}
|