user(); $toolsHfOnly = true; $filters = [ 'type' => (string) $request->query('gallery_type', 'all'), 'provider' => (string) $request->query('gallery_provider', 'all'), 'q' => trim((string) $request->query('gallery_q', '')), ]; $applyGalleryFilters = function ($query) use ($filters) { if ($filters['provider'] !== '' && $filters['provider'] !== 'all') { $query->where('provider', $filters['provider']); } if ($filters['q'] !== '') { $query->where(function ($q) use ($filters) { $q->where('prompt', 'like', '%'.$filters['q'].'%') ->orWhere('url', 'like', '%'.$filters['q'].'%'); }); } return $query; }; $imageQuery = $user->socialMediaAssets()->where('type', 'image'); $videoQuery = $user->socialMediaAssets()->where('type', 'video'); $toolImageGallery = $filters['type'] === 'video' ? collect() : $applyGalleryFilters($imageQuery)->latest('id')->limit(30)->get(); $toolVideoGallery = $filters['type'] === 'image' ? collect() : $applyGalleryFilters($videoQuery)->latest('id')->limit(30)->get(); $providers = $user->socialMediaAssets() ->select('provider') ->whereNotNull('provider') ->distinct() ->orderBy('provider') ->pluck('provider') ->filter() ->values(); $stockFilters = [ 'q' => trim((string) $request->query('stock_q', '')), 'type' => (string) $request->query('stock_type', 'all'), 'order' => (string) $request->query('stock_order', 'relevance'), 'page' => max(1, (int) $request->query('stock_page', 1)), ]; $stockFilters['type'] = in_array($stockFilters['type'], ['all', 'images', 'videos', 'audio', 'icons'], true) ? $stockFilters['type'] : 'all'; $stockFilters['order'] = in_array($stockFilters['order'], ['relevance', 'recent'], true) ? $stockFilters['order'] : 'relevance'; $stockSearch = [ 'results' => [ 'images' => [], 'videos' => [], 'icons' => [], 'audio' => [], ], 'errors' => [], 'summary' => [ 'images' => 0, 'videos' => 0, 'icons' => 0, 'audio' => 0, ], ]; $stockSearchError = null; if ($stockFilters['q'] !== '') { $stockSearchError = 'Layanan Freepik stock tidak tersedia. Gunakan Image Generator, Remix Lab, atau upload asset sendiri.'; } return view('app.index', [ 'ttsLanguages' => $tts->languageOptions(), 'ttsGenders' => $tts->genderOptions(), 'toolImageGallery' => $toolImageGallery, 'toolVideoGallery' => $toolVideoGallery, 'galleryFilters' => $filters, 'galleryProviders' => $providers, 'stockFilters' => $stockFilters, 'stockSearch' => $stockSearch, 'stockSearchError' => $stockSearchError, 'toolsHfOnly' => $toolsHfOnly, ]); } public function generateVideo(Request $request, SocialMediaStudioService $studio, HuggingFaceMediaStorageService $hfMedia, ReplicateVideoService $replicate): RedirectResponse|JsonResponse { $expectsJson = $this->shouldReturnJson($request); // Debug: Check table if (! \Illuminate\Support\Facades\Schema::hasTable('social_media_assets')) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Database table social_media_assets is missing. Please contact admin to run migrations.', ], 500); } return redirect()->route('tools.index')->with('tool_error', 'Database table social_media_assets is missing. Please contact admin to run migrations.')->withInput(); } $data = $request->validate([ 'prompt' => ['nullable', 'string', 'max:2000'], 'source_image_id' => ['nullable', 'integer'], 'end_image_id' => ['nullable', 'integer'], 'source_image_file' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'end_image_file' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'video_provider' => ['nullable', 'string', 'in:runway,gemini,huggingface,openai,replicate'], 'video_model' => ['nullable', 'string', 'in:minimax,veo,luma-reframe,topaz-video-upscale'], 'source_video_id' => ['nullable', 'integer'], 'aspect_ratio' => ['nullable', 'string', 'in:9:16,16:9,1:1,4:5'], 'video_quality' => ['nullable', 'string', 'in:720p,1080p,preview'], 'video_duration' => ['nullable', 'string', 'in:4s,5s,6s,8s'], 'veo_variant' => ['nullable', 'string', 'in:veo-3-fast,veo-3'], 'replicate_i2v_model' => ['nullable', 'string', 'in:wavespeedai/wan-2.1-i2v-480p'], 'video_audio' => ['nullable', 'string', 'in:on,off'], 'video_negative_prompt' => ['nullable', 'string', 'max:1000'], ]); $videoModel = $data['video_model'] ?? 'minimax'; $requestedVideoProvider = $data['video_provider'] ?? 'replicate'; $videoProvider = in_array($requestedVideoProvider, ['runway', 'gemini', 'huggingface', 'openai', 'replicate'], true) ? $requestedVideoProvider : 'replicate'; $videoSettings = [ 'video_provider' => $videoProvider, 'video_model' => $videoModel, 'aspect_ratio' => ($data['aspect_ratio'] ?? '16:9') === '9:16' ? '9:16' : '16:9', 'video_quality' => ($data['video_quality'] ?? '720p') === '720p' ? '720p' : '1080p', 'video_duration' => $data['video_duration'] ?? '4s', 'veo_variant' => ($data['veo_variant'] ?? 'veo-3-fast') === 'veo-3' ? 'veo-3' : 'veo-3-fast', 'replicate_i2v_model' => $data['replicate_i2v_model'] ?? 'wavespeedai/wan-2.1-i2v-480p', 'video_audio' => ($data['video_audio'] ?? 'off') === 'on' ? 'on' : 'off', 'video_negative_prompt' => trim((string) ($data['video_negative_prompt'] ?? '')), ]; // ── luma/reframe-video ──────────────────────────────────────────────── if ($videoModel === 'luma-reframe') { $sourceVideoId = $data['source_video_id'] ?? null; if (empty($sourceVideoId)) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Pilih video sumber dari gallery untuk Luma Reframe.', ], 422); } return redirect()->route('tools.index')->with('tool_error', 'Pilih video sumber dari gallery untuk Luma Reframe.')->withInput(); } $asset = SocialMediaAsset::where('id', $sourceVideoId) ->where('user_id', $request->user()->id) ->where('type', 'video') ->first(); if (! $asset) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Video sumber tidak ditemukan.', ], 404); } return redirect()->route('tools.index')->with('tool_error', 'Video sumber tidak ditemukan.')->withInput(); } try { $predictionId = $replicate->submitReframe($asset->url, $data['aspect_ratio'] ?? '9:16'); $pendingPayload = [ 'tool_video_pending' => true, 'tool_video_prediction_id' => $predictionId, 'tool_video_provider' => 'replicate-i2v', 'tool_video_prompt' => 'Luma Reframe → ' . ($data['aspect_ratio'] ?? '9:16'), 'tool_video_source_image_url' => $asset->url, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_effective_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_effective_quality' => $videoSettings['video_quality'], 'tool_video_effective_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_effective_veo_variant' => $videoSettings['veo_variant'], 'tool_video_effective_audio' => $videoSettings['video_audio'], ]; if ($expectsJson) { return response()->json([ 'status' => 'processing', 'prediction_id' => $predictionId, 'provider' => 'replicate-i2v', 'prompt' => $pendingPayload['tool_video_prompt'], 'source_image_url' => $asset->url, 'poll_url' => route('tools.video.poll', [ 'prediction_id' => $predictionId, 'provider' => 'replicate-i2v', 'prompt' => $pendingPayload['tool_video_prompt'], 'source_image_url' => $asset->url, ]), ], 202); } return redirect()->route('tools.index')->with([ 'tool_video_pending' => true, 'tool_video_prediction_id' => $predictionId, 'tool_video_prompt' => 'Luma Reframe → ' . ($data['aspect_ratio'] ?? '9:16'), 'tool_video_source_image_url' => $asset->url, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_effective_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_effective_quality' => $videoSettings['video_quality'], 'tool_video_effective_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_effective_veo_variant' => $videoSettings['veo_variant'], 'tool_video_effective_audio' => $videoSettings['video_audio'], ]); } catch (\Throwable $e) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $e->getMessage(), ], 500); } return redirect()->route('app.index')->with('tool_error', $e->getMessage())->withInput(); } } // ── topazlabs/video-upscale ─────────────────────────────────────────── if ($videoModel === 'topaz-video-upscale') { $sourceVideoId = $data['source_video_id'] ?? null; if (empty($sourceVideoId)) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Pilih video sumber dari gallery untuk Topaz Video Upscale.', ], 422); } return redirect()->route('tools.index')->with('tool_error', 'Pilih video sumber dari gallery untuk Topaz Video Upscale.')->withInput(); } $asset = SocialMediaAsset::where('id', $sourceVideoId) ->where('user_id', $request->user()->id) ->where('type', 'video') ->first(); if (! $asset) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Video sumber tidak ditemukan.', ], 404); } return redirect()->route('tools.index')->with('tool_error', 'Video sumber tidak ditemukan.')->withInput(); } try { $predictionId = $replicate->submitVideoUpscale($asset->url); $pendingPayload = [ 'tool_video_pending' => true, 'tool_video_prediction_id' => $predictionId, 'tool_video_provider' => 'replicate-i2v', 'tool_video_prompt' => 'Topaz Video Upscale', 'tool_video_source_image_url' => $asset->url, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_effective_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_effective_quality' => $videoSettings['video_quality'], 'tool_video_effective_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_effective_veo_variant' => $videoSettings['veo_variant'], 'tool_video_effective_audio' => $videoSettings['video_audio'], ]; if ($expectsJson) { return response()->json([ 'status' => 'processing', 'prediction_id' => $predictionId, 'provider' => 'replicate-i2v', 'prompt' => $pendingPayload['tool_video_prompt'], 'source_image_url' => $asset->url, 'poll_url' => route('tools.video.poll', [ 'prediction_id' => $predictionId, 'provider' => 'replicate-i2v', 'prompt' => $pendingPayload['tool_video_prompt'], 'source_image_url' => $asset->url, ]), ], 202); } return redirect()->route('tools.index')->with([ 'tool_video_pending' => true, 'tool_video_prediction_id' => $predictionId, 'tool_video_prompt' => 'Topaz Video Upscale', 'tool_video_source_image_url' => $asset->url, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_effective_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_effective_quality' => $videoSettings['video_quality'], 'tool_video_effective_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_effective_veo_variant' => $videoSettings['veo_variant'], 'tool_video_effective_audio' => $videoSettings['video_audio'], ]); } catch (\Throwable $e) { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $e->getMessage(), ], 500); } return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } // ── minimax/video-01 (default) ──────────────────────────────────────── $data['prompt'] = trim((string) ($data['prompt'] ?? '')); if ($data['prompt'] === '') { if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Prompt video wajib diisi untuk generate video.', ], 422); } return redirect()->route('tools.index')->with('tool_error', 'Prompt video wajib diisi untuk generate video.')->withInput(); } $fullPrompt = trim($data['prompt']); try { $sourceImageUrl = null; $endImageUrl = null; $endImageDescription = ''; $uploadedSourceAssetPayload = null; $uploadedEndAssetPayload = null; // Option A: use gallery image if (! empty($data['source_image_id'])) { $asset = SocialMediaAsset::where('id', $data['source_image_id']) ->where('user_id', $request->user()->id) ->where('type', 'image') ->first(); if ($asset) { $sourceImageUrl = $asset->url; } } // Option B: upload a new source image if ($sourceImageUrl === null && $request->hasFile('source_image_file')) { $file = $request->file('source_image_file'); $ext = strtolower((string) $file->getClientOriginalExtension()) ?: 'jpg'; $stored = $file->storeAs('social_assets/images', now()->format('Ymd_His').'_'.$request->user()->id.'_'.Str::lower(Str::random(8)).'.'.$ext, 'public'); $sourceImageUrl = $hfMedia->offloadPublicRelativePath($stored); $uploadedSourceAssetPayload = [ 'user_id' => $request->user()->id, 'type' => 'image', 'provider' => 'upload', 'prompt' => $fullPrompt, 'url' => $sourceImageUrl, 'thumbnail_url' => $sourceImageUrl, 'meta' => ['source' => 'video_source_upload'], ]; } if ($request->hasFile('end_image_file')) { $endFile = $request->file('end_image_file'); $endExt = strtolower((string) $endFile->getClientOriginalExtension()) ?: 'jpg'; $endStored = $endFile->storeAs('social_assets/images', now()->format('Ymd_His').'_'.$request->user()->id.'_end_'.Str::lower(Str::random(8)).'.'.$endExt, 'public'); $endImageUrl = $hfMedia->offloadPublicRelativePath($endStored); try { $endImageDescription = trim((string) app(OpenAiChatService::class)->describeImage($endImageUrl)); } catch (\Throwable $visionError) { report($visionError); } $uploadedEndAssetPayload = [ 'user_id' => $request->user()->id, 'type' => 'image', 'provider' => 'upload', 'prompt' => $endImageDescription !== '' ? $endImageDescription : 'End frame reference', 'url' => $endImageUrl, 'thumbnail_url' => $endImageUrl, 'meta' => ['source' => 'video_end_frame_upload'], ]; } elseif (! empty($data['end_image_id'])) { $endAsset = SocialMediaAsset::where('id', $data['end_image_id']) ->where('user_id', $request->user()->id) ->where('type', 'image') ->first(); if ($endAsset) { $endImageUrl = $endAsset->url; $endImageDescription = trim((string) ($endAsset->prompt ?? '')); } } if ($endImageDescription !== '') { $fullPrompt .= '. End frame target reference: ' . $endImageDescription . '.'; } elseif ($endImageUrl !== null) { $fullPrompt .= '. End frame target reference uploaded for the final moment of the shot.'; } if ($endImageUrl !== null) { $videoSettings['end_frame_url'] = $endImageUrl; } if ($endImageDescription !== '') { $videoSettings['end_frame_description'] = $endImageDescription; } $video = $sourceImageUrl !== null ? $studio->generateVideoFromImageUrl($sourceImageUrl, $fullPrompt, $videoSettings) : $studio->generateVideo($fullPrompt, $videoSettings); $appliedVideoSettings = array_merge($videoSettings, (array) ($video['applied_settings'] ?? [])); $endFrameNotice = $endImageDescription !== '' ? ' End frame dipakai sebagai target reference: '.$endImageDescription.'.' : ($endImageUrl !== null ? ' End frame upload diterima sebagai target reference untuk brief motion.' : ''); if ($uploadedSourceAssetPayload !== null) { SocialMediaAsset::query()->create($uploadedSourceAssetPayload); } if ($uploadedEndAssetPayload !== null) { SocialMediaAsset::query()->create($uploadedEndAssetPayload); } // ── Async path (Replicate) ──────────────────────────────────────── if (! empty($video['async']) && ! empty($video['prediction_id'])) { $pendingPayload = [ 'tool_video_pending' => true, 'tool_video_prediction_id' => $video['prediction_id'], 'tool_video_provider' => $video['provider'] ?? 'replicate-i2v', 'tool_video_provider_selected' => $videoProvider, 'tool_video_prompt' => $data['prompt'], 'tool_video_source_image_url' => $video['source_image_url'] ?? $sourceImageUrl, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_end_image_id' => $data['end_image_id'] ?? null, 'tool_video_effective_aspect_ratio' => $appliedVideoSettings['aspect_ratio'], 'tool_video_effective_quality' => $appliedVideoSettings['video_quality'], 'tool_video_effective_duration' => $appliedVideoSettings['video_duration'], 'tool_video_effective_veo_variant' => $appliedVideoSettings['veo_variant'] ?? $videoSettings['veo_variant'], 'tool_video_effective_audio' => $appliedVideoSettings['video_audio'] ?? $videoSettings['video_audio'], 'tool_video_notice' => trim(((string) ($video['notice'] ?? '')) . $endFrameNotice), ]; if ($expectsJson) { return response()->json([ 'status' => 'processing', 'prediction_id' => $video['prediction_id'], 'provider' => $video['provider'] ?? 'replicate-i2v', 'prompt' => $data['prompt'], 'source_image_url' => $video['source_image_url'] ?? $sourceImageUrl, 'poll_url' => route('tools.video.poll', [ 'prediction_id' => $video['prediction_id'], 'provider' => $video['provider'] ?? 'replicate-i2v', 'prompt' => $data['prompt'], 'source_image_url' => $video['source_image_url'] ?? $sourceImageUrl, ]), 'notice' => $pendingPayload['tool_video_notice'], 'effective_settings' => [ 'aspect_ratio' => $appliedVideoSettings['aspect_ratio'], 'video_quality' => $appliedVideoSettings['video_quality'], 'video_duration' => $appliedVideoSettings['video_duration'], ], ], 202); } return redirect()->route('tools.index')->with([ 'tool_video_pending' => true, 'tool_video_prediction_id' => $video['prediction_id'], 'tool_video_provider' => $video['provider'] ?? 'replicate-i2v', 'tool_video_provider_selected' => $videoProvider, 'tool_video_prompt' => $data['prompt'], 'tool_video_source_image_url' => $video['source_image_url'] ?? null, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_end_image_id' => $data['end_image_id'] ?? null, 'tool_video_effective_aspect_ratio' => $appliedVideoSettings['aspect_ratio'], 'tool_video_effective_quality' => $appliedVideoSettings['video_quality'], 'tool_video_effective_duration' => $appliedVideoSettings['video_duration'], 'tool_video_effective_veo_variant' => $appliedVideoSettings['veo_variant'] ?? $videoSettings['veo_variant'], 'tool_video_effective_audio' => $appliedVideoSettings['video_audio'] ?? $videoSettings['video_audio'], 'tool_video_notice' => trim(((string) ($video['notice'] ?? '')) . $endFrameNotice), ]); } // ── Sync path (FFmpeg fallback) ─────────────────────────────────── if (blank($video['url'] ?? null)) { throw new \RuntimeException('Video URL kosong, jadi belum bisa disimpan ke gallery.'); } $saved = $this->saveGeneratedVideoAsset($request->user()->id, $video); $successNotice = trim(((string) ($video['notice'] ?? 'Video berhasil dibuat dan disimpan ke gallery.')) . $endFrameNotice); if ($expectsJson) { return response()->json([ 'status' => 'succeeded', 'url' => $saved['url'] ?? null, 'provider' => $video['provider'] ?? $videoProvider, 'asset_id' => $saved['id'] ?? null, 'gallery_item' => $saved, 'notice' => $successNotice, 'effective_settings' => [ 'aspect_ratio' => $appliedVideoSettings['aspect_ratio'], 'video_quality' => $appliedVideoSettings['video_quality'], 'video_duration' => $appliedVideoSettings['video_duration'], ], ]); } return redirect()->route('tools.index')->with([ 'tool_video_url' => $saved['url'] ?? null, 'tool_video_provider' => $video['provider'] ?? $videoProvider, 'tool_video_provider_selected' => $videoProvider, 'tool_video_prompt' => $data['prompt'], 'tool_video_notice' => $successNotice, 'tool_video_aspect_ratio' => $videoSettings['aspect_ratio'], 'tool_video_quality' => $videoSettings['video_quality'], 'tool_video_duration' => $videoSettings['video_duration'], 'tool_video_veo_variant' => $videoSettings['veo_variant'], 'tool_video_audio' => $videoSettings['video_audio'], 'tool_video_negative_prompt' => $videoSettings['video_negative_prompt'], 'tool_video_end_image_id' => $data['end_image_id'] ?? null, 'tool_video_effective_aspect_ratio' => $appliedVideoSettings['aspect_ratio'], 'tool_video_effective_quality' => $appliedVideoSettings['video_quality'], 'tool_video_effective_duration' => $appliedVideoSettings['video_duration'], 'tool_video_effective_veo_variant' => $appliedVideoSettings['veo_variant'] ?? $videoSettings['veo_variant'], 'tool_video_effective_audio' => $appliedVideoSettings['video_audio'] ?? $videoSettings['video_audio'], ]); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('Video Generation Error: ' . $e->getMessage(), [ 'exception' => $e, 'prompt' => $data['prompt'] ?? null, ]); if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $e->getMessage(), ], 500); } return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } /** * AJAX polling endpoint — called by frontend every 5s while waiting for Replicate. * Returns JSON: { status, url?, error? } */ public function pollVideo(Request $request, ReplicateVideoService $replicate, GeminiVideoService $gemini, OpenAiVideoService $openAiVideo, \App\Services\FreepikAssetRemixService $freepikAssetRemix): JsonResponse { $predictionId = (string) $request->query('prediction_id', ''); $provider = (string) $request->query('provider', 'replicate-i2v'); if (blank($predictionId)) { return response()->json(['status' => 'failed', 'error' => 'prediction_id kosong.'], 400); } $result = match ($provider) { 'gemini-veo' => $gemini->checkStatus($predictionId), 'openai-sora' => $openAiVideo->checkStatus($predictionId), 'freepik-runway-i2v' => $freepikAssetRemix->checkRemixVideoStatus($predictionId), default => $replicate->checkStatus($predictionId), }; // When succeeded: save to DB and return final asset URL if ($result['status'] === 'succeeded' && ! blank($result['url'] ?? null)) { $userId = $request->user()->id; $prompt = (string) $request->query('prompt', ''); $sourceImageUrl = (string) $request->query('source_image_url', ''); try { $asset = SocialMediaAsset::query()->firstOrCreate( [ 'user_id' => $userId, 'type' => 'video', 'url' => $result['url'], ], [ 'provider' => (string) ($result['provider'] ?? $provider ?: 'replicate-i2v'), 'prompt' => $prompt, 'thumbnail_url' => $sourceImageUrl !== '' ? $sourceImageUrl : $result['url'], 'meta' => [ 'source_image_url' => $sourceImageUrl, 'prediction_id' => $predictionId, 'provider' => (string) ($result['provider'] ?? $provider ?: 'replicate-i2v'), 'remote_url' => (string) ($result['remote_url'] ?? ''), 'source' => 'tools_async_poll', ], ] ); if ($asset->wasRecentlyCreated === false) { $asset->forceFill([ 'provider' => $asset->provider ?: (string) ($result['provider'] ?? $provider ?: 'replicate-i2v'), 'prompt' => $asset->prompt ?: $prompt, 'thumbnail_url' => $asset->thumbnail_url ?: ($sourceImageUrl !== '' ? $sourceImageUrl : $result['url']), 'meta' => array_merge($asset->meta ?? [], [ 'source_image_url' => $sourceImageUrl, 'prediction_id' => $predictionId, 'provider' => (string) ($result['provider'] ?? $provider ?: 'replicate-i2v'), 'remote_url' => (string) ($result['remote_url'] ?? ''), 'source' => 'tools_async_poll', ]), ])->save(); } $result['asset_id'] = $asset->id; $result['gallery_item'] = $this->formatVideoGalleryItem($asset); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('pollVideo: DB save failed', ['error' => $e->getMessage()]); } } return response()->json($result); } public function faceSwap(Request $request, SocialMediaStudioService $studio): RedirectResponse { $data = $request->validate([ 'source_asset_id' => ['required', 'exists:social_media_assets,id'], 'prompt' => ['required', 'string', 'max:2000'], ]); try { $asset = SocialMediaAsset::findOrFail($data['source_asset_id']); if ($asset->user_id !== $request->user()->id) { abort(403); } $source = [ 'id' => $asset->id, 'url' => $asset->url, 'type' => $asset->type, 'thumbnail_url' => $asset->thumbnail_url, 'prompt' => (string) ($asset->prompt ?? ''), ]; // Kita gunakan remixFromAsset dengan mode 'strict_face_swap' untuk Face Studio $result = $studio->remixFromAsset($source, $data['prompt'], 'strict_face_swap', 'image'); if (blank($result['url'] ?? null)) { throw new \RuntimeException('Face swap result URL kosong.'); } $saved = $this->saveGeneratedImageAsset($request->user()->id, $result); return redirect()->route('tools.index')->with([ 'tool_face_url' => $saved['url'] ?? null, 'tool_face_prompt' => $data['prompt'], 'tool_face_source_id' => $data['source_asset_id'], 'tool_face_notice' => $result['notice'] ?? 'Face identity swap berhasil diproses.', ]); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('Face Swap Error: ' . $e->getMessage(), [ 'exception' => $e, 'source_asset_id' => $data['source_asset_id'] ?? null, 'prompt' => $data['prompt'] ?? null, ]); return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } public function generateImage( Request $request, HuggingFaceImageService $hfImage, PollinationsImageService $pollinations, ReplicateImageService $replicateImage, HuggingFaceMediaStorageService $hfMedia ): RedirectResponse|JsonResponse { $expectsJson = $this->shouldReturnJson($request); $data = $request->validate([ 'prompt' => ['required', 'string', 'max:2000'], 'aspect' => ['nullable', 'in:landscape,square,portrait'], 'image_count' => ['nullable', 'in:1,2,4,6'], 'provider' => ['nullable', 'in:auto,huggingface,pollinations,replicate-imagen4,replicate-flux,replicate-ideogram,replicate-flux-flex'], 'card_template_type' => ['nullable', 'string', 'in:employee-id,business-name'], 'template_reference_file_1' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'template_reference_file_2' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], ]); $aspect = $data['aspect'] ?? 'landscape'; $imageCount = (int) ($data['image_count'] ?? 1); $provider = $data['provider'] ?? 'auto'; $templateReferencePayload = $this->augmentImagePromptWithTemplateReferences( $request, $data['prompt'], $request->user()->id, $hfMedia ); $finalPrompt = $templateReferencePayload['prompt']; $templateReferenceUrls = $templateReferencePayload['urls']; if ($provider !== 'auto') { try { $resolvedProvider = $this->normalizeImageProvider($provider); $imageUrls = $this->generateFromProvider($resolvedProvider, $finalPrompt, $aspect, $imageCount, $hfImage, $pollinations, $replicateImage, $templateReferenceUrls); if ($expectsJson) { return $this->jsonWithImageResult( $imageUrls, $data['prompt'], $aspect, $imageCount, $resolvedProvider, 'Provider manual: '.strtoupper($resolvedProvider), $request->user()->id ); } return $this->redirectWithImageResult( $imageUrls, $data['prompt'], $aspect, $imageCount, $resolvedProvider, 'Provider manual: '.strtoupper($resolvedProvider), $resolvedProvider, $request->user()->id ); } catch (\Throwable $e) { // If provider ran out of credits / unavailable, fall through to Pollinations silently if ($this->isProviderUnavailable($e)) { try { $imageUrls = $pollinations->generate($finalPrompt, $aspect, $imageCount); if ($expectsJson) { return $this->jsonWithImageResult( $imageUrls, $data['prompt'], $aspect, $imageCount, 'pollinations', 'Provider '.strtoupper($provider).' tidak tersedia (kredit habis atau limit). Diproses lewat Pollinations.', $request->user()->id ); } return $this->redirectWithImageResult($imageUrls, $data['prompt'], $aspect, $imageCount, 'pollinations', 'Provider '.strtoupper($provider).' tidak tersedia (kredit habis atau limit). Diproses lewat Pollinations.', $provider, $request->user()->id); } catch (\Throwable) { // fall through to error } } if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $e->getMessage(), ], 422); } return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } // Auto mode: HF → Pollinations try { $imageUrls = $hfImage->generate($finalPrompt, $aspect, $imageCount); if ($expectsJson) { return $this->jsonWithImageResult( $imageUrls, $data['prompt'], $aspect, $imageCount, 'huggingface', null, $request->user()->id ); } return $this->redirectWithImageResult($imageUrls, $data['prompt'], $aspect, $imageCount, 'huggingface', null, 'auto', $request->user()->id); } catch (\Throwable $hfError) { try { $imageUrls = $pollinations->generate($finalPrompt, $aspect, $imageCount); if ($expectsJson) { return $this->jsonWithImageResult( $imageUrls, $data['prompt'], $aspect, $imageCount, 'pollinations', 'Hugging Face tidak tersedia, diproses lewat Pollinations.', $request->user()->id ); } return $this->redirectWithImageResult($imageUrls, $data['prompt'], $aspect, $imageCount, 'pollinations', 'Hugging Face tidak tersedia, diproses lewat Pollinations.', 'auto', $request->user()->id); } catch (\Throwable $pollinationsError) { $message = 'HuggingFace: '.$hfError->getMessage(); $message .= ' | Pollinations: '.$pollinationsError->getMessage(); if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => 'Semua provider gagal. '.$message, ], 422); } return redirect()->route('app.index') ->with('tool_error', 'Semua provider gagal. '.$message) ->withInput(); } } } public function generateTemplatePoster( Request $request, SocialMediaStudioService $studio, HuggingFaceMediaStorageService $hfMedia, OpenAiChatService $openAiChat ): RedirectResponse|JsonResponse { $expectsJson = $this->shouldReturnJson($request); $genreOptions = $this->moviePosterGenres(); $data = $request->validate([ 'template_slug' => ['required', 'string', 'in:movie-poster'], 'genre' => ['required', 'string', 'in:'.implode(',', array_keys($genreOptions))], 'source_asset_id' => ['nullable', 'integer'], 'source_image_file' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'custom_brief' => ['nullable', 'string', 'max:500'], ]); try { $sourceAsset = null; if (! empty($data['source_asset_id'])) { $sourceAsset = SocialMediaAsset::query() ->where('id', $data['source_asset_id']) ->where('user_id', $request->user()->id) ->where('type', 'image') ->first(); } if (! $sourceAsset && $request->hasFile('source_image_file')) { $file = $request->file('source_image_file'); $ext = strtolower((string) $file->getClientOriginalExtension()) ?: 'jpg'; $stored = $file->storeAs( 'social_assets/images', now()->format('Ymd_His').'_'.$request->user()->id.'_template_'.Str::lower(Str::random(8)).'.'.$ext, 'public' ); $publicUrl = $hfMedia->offloadPublicRelativePath($stored); $detectedPrompt = ''; try { $detectedPrompt = trim((string) $openAiChat->describeImage($publicUrl)); } catch (\Throwable $visionError) { report($visionError); } $sourceAsset = SocialMediaAsset::query()->create([ 'user_id' => $request->user()->id, 'type' => 'image', 'provider' => 'upload', 'prompt' => $detectedPrompt !== '' ? $detectedPrompt : 'Uploaded portrait for template', 'url' => $publicUrl, 'thumbnail_url' => $publicUrl, 'meta' => [ 'source' => 'template_upload', 'ai_detected_prompt' => $detectedPrompt, ], ]); } if (! $sourceAsset) { $message = 'Pilih foto dari gallery atau upload portrait terlebih dahulu.'; if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $message, ], 422); } return redirect()->route('tools.index')->with('tool_error', $message)->withInput(); } $genre = (string) $data['genre']; $brief = trim((string) ($data['custom_brief'] ?? '')); $templatePrompt = $this->composeMoviePosterPrompt($genre, $brief); $media = $studio->remixFromAsset([ 'type' => 'image', 'url' => (string) $sourceAsset->url, 'thumbnail_url' => (string) ($sourceAsset->thumbnail_url ?: $sourceAsset->url), 'prompt' => (string) ($sourceAsset->prompt ?? ''), ], $templatePrompt, 'face_swap', 'image'); $saved = $this->saveGeneratedImageAsset($request->user()->id, [ ...$media, 'prompt' => 'Movie Poster · '.$genreOptions[$genre]['label'], 'meta' => [ 'template_slug' => 'movie-poster', 'genre' => $genre, 'source_asset_id' => $sourceAsset->id, ], ]); $notice = 'Template Movie Poster berhasil dibuat dan masuk ke gallery image.'; if ($expectsJson) { return response()->json([ 'status' => 'succeeded', 'asset_id' => $saved['id'] ?? null, 'gallery_item' => $saved, 'notice' => $notice, ]); } return redirect()->route('tools.index')->with([ 'tool_notice' => $notice, 'tool_image_urls' => [$saved['url'] ?? null], 'tool_image_url' => $saved['url'] ?? null, 'tool_image_prompt' => $saved['display_prompt'] ?? 'Movie Poster', 'tool_image_aspect' => 'portrait', 'tool_image_count' => 1, 'tool_image_provider' => (string) ($media['provider'] ?? 'template'), 'tool_image_provider_selected' => 'auto', ]); } catch (\Throwable $e) { report($e); if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $e->getMessage(), ], 500); } return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } public function textToVoice(Request $request, EdgeTtsService $tts): RedirectResponse|JsonResponse { $languageKeys = implode(',', array_keys($tts->languageOptions())); $genderKeys = implode(',', array_keys($tts->genderOptions())); $data = $request->validate([ 'text' => ['required', 'string', 'max:8000'], 'language' => ['required', 'string', "in:{$languageKeys}"], 'gender' => ['required', 'string', "in:{$genderKeys}"], 'engine' => ['nullable', 'string', 'in:edge,kokoro'], ]); try { $engine = $data['engine'] ?? 'edge'; $audioUrl = $tts->synthesize($data['text'], $data['language'], $data['gender'], $engine); if ($request->expectsJson()) { session()->flash('tool_audio_url', $audioUrl); session()->flash('tool_audio_text', $data['text']); session()->flash('tool_audio_language', $data['language']); session()->flash('tool_audio_gender', $data['gender']); session()->flash('tool_audio_engine', $engine); return response()->json([ 'success' => true, 'audio_url' => $audioUrl, 'text' => $data['text'], 'language' => $data['language'], 'gender' => $data['gender'], ]); } return redirect()->route('tools.index')->with([ 'tool_audio_url' => $audioUrl, 'tool_audio_text' => $data['text'], 'tool_audio_language' => $data['language'], 'tool_audio_gender' => $data['gender'], ]); } catch (\Throwable $e) { if ($request->expectsJson()) { return response()->json([ 'success' => false, 'message' => $e->getMessage(), ], 500); } return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } public function generateTryOn( Request $request, ReplicateTryOnService $replicateTryOn, HuggingFaceMediaStorageService $hfMedia ): RedirectResponse|JsonResponse { $expectsJson = $this->shouldReturnJson($request); $data = $request->validate([ 'source_asset_id' => ['nullable', 'integer'], 'source_image_file' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'outfit_file_1' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'outfit_file_2' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'outfit_file_3' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'layout_json' => ['nullable', 'string', 'max:12000'], 'ai_brief' => ['nullable', 'string', 'max:4000'], ]); $sourceUrl = ''; $primaryOutfitUrl = ''; $userId = (int) $request->user()->id; try { if (! $replicateTryOn->isAvailable()) { throw new \RuntimeException('REPLICATE_API_KEY belum diisi di server, jadi Try On Outfit belum bisa diproses.'); } $layout = $this->decodeTryOnLayout((string) ($data['layout_json'] ?? '')); $aiBrief = trim((string) ($data['ai_brief'] ?? '')); $uploadedOutfitSlots = collect([1, 2, 3]) ->filter(fn (int $slot): bool => $request->hasFile('outfit_file_'.$slot)) ->values() ->all(); if ($uploadedOutfitSlots === []) { $message = 'Upload minimal satu reference outfit terlebih dahulu.'; if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $message, ], 422); } return redirect()->route('tools.index')->with('tool_error', $message)->withInput(); } if (! empty($data['source_asset_id'])) { $sourceAsset = SocialMediaAsset::query() ->where('id', $data['source_asset_id']) ->where('user_id', $userId) ->where('type', 'image') ->first(); if ($sourceAsset) { $sourceUrl = (string) $sourceAsset->url; } } if ($sourceUrl === '' && $request->hasFile('source_image_file')) { $sourceUrl = $this->storeTemporaryToolImage( $request->file('source_image_file'), $userId, 'tryon_source', $hfMedia ); } if ($sourceUrl === '') { $message = 'Pilih foto orang dari gallery atau upload Your Photo terlebih dahulu.'; if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $message, ], 422); } return redirect()->route('tools.index')->with('tool_error', $message)->withInput(); } [$replicateInputs, $usedSlots, $primaryOutfitSlot] = $this->buildReplicateTryOnInputs( $request, $uploadedOutfitSlots, $layout, $aiBrief, $userId, $hfMedia ); $primaryOutfitUrl = $replicateInputs !== [] ? (string) reset($replicateInputs) : ''; Log::info('Try On Outfit request submitted.', [ 'user_id' => $userId, 'source_asset_id' => $data['source_asset_id'] ?? null, 'uploaded_outfit_slots' => $uploadedOutfitSlots, 'used_slots' => $usedSlots, 'layout_count' => count($layout), ]); $submission = $replicateTryOn->submit($sourceUrl, $replicateInputs, [ 'prompt' => $aiBrief !== '' ? $aiBrief : 'Virtual try on result', ]); $notice = count($usedSlots) > 1 ? 'Try On Outfit masuk queue Replicate. Untuk model ini saya pakai outfit utama dari sticker pertama yang Anda tempatkan di canvas.' : 'Try On Outfit masuk queue Replicate dan sedang diproses.'; if ($expectsJson) { return response()->json([ 'status' => 'processing', 'notice' => $notice, 'prediction_id' => $submission['prediction_id'] ?? null, 'provider' => $submission['provider'] ?? 'replicate-tryon', 'used_outfit_slot' => $primaryOutfitSlot, 'used_slots' => $usedSlots, 'poll_url' => route('tools.try-on.poll', [ 'prediction_id' => $submission['prediction_id'] ?? '', 'source_asset_id' => $data['source_asset_id'] ?? '', 'source_url' => $sourceUrl, 'prompt' => $aiBrief !== '' ? $aiBrief : 'Virtual try on result', 'used_slots' => base64_encode(json_encode($usedSlots)), ]), ], 202); } return redirect()->route('tools.index')->with('tool_notice', $notice); } catch (\Throwable $e) { Log::error('Try On Outfit failed.', [ 'user_id' => $userId, 'error' => $e->getMessage(), 'source_url' => $sourceUrl, 'primary_outfit_url' => $primaryOutfitUrl, ]); if ($expectsJson) { return response()->json([ 'status' => 'failed', 'error' => $e->getMessage(), ], 500); } return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } public function pollTryOn(Request $request, ReplicateTryOnService $replicateTryOn): JsonResponse { $predictionId = trim((string) $request->query('prediction_id', '')); if ($predictionId === '') { return response()->json([ 'status' => 'failed', 'error' => 'prediction_id kosong.', ], 400); } $result = $replicateTryOn->checkStatus($predictionId); if (($result['status'] ?? '') === 'processing') { return response()->json($result); } if (($result['status'] ?? '') === 'succeeded' && ! blank($result['url'] ?? null)) { $userId = (int) $request->user()->id; $prompt = trim((string) $request->query('prompt', '')); $sourceUrl = trim((string) $request->query('source_url', '')); $usedSlotsPayload = (string) $request->query('used_slots', ''); $usedSlots = json_decode(base64_decode($usedSlotsPayload, true) ?: '[]', true); try { $asset = SocialMediaAsset::query()->firstOrCreate( [ 'user_id' => $userId, 'type' => 'image', 'url' => (string) $result['url'], ], [ 'provider' => 'replicate-tryon', 'prompt' => $prompt !== '' ? $prompt : 'Replicate virtual try on result', 'thumbnail_url' => (string) ($result['thumbnail_url'] ?? $result['url']), 'meta' => [ 'source' => 'tools_tryon_async_poll', 'prediction_id' => $predictionId, 'source_url' => $sourceUrl, 'used_slots' => is_array($usedSlots) ? $usedSlots : [], ], ] ); if ($asset->wasRecentlyCreated === false) { $asset->forceFill([ 'provider' => $asset->provider ?: 'replicate-tryon', 'prompt' => $asset->prompt ?: ($prompt !== '' ? $prompt : 'Replicate virtual try on result'), 'thumbnail_url' => $asset->thumbnail_url ?: (string) ($result['thumbnail_url'] ?? $result['url']), 'meta' => array_merge($asset->meta ?? [], [ 'source' => 'tools_tryon_async_poll', 'prediction_id' => $predictionId, 'source_url' => $sourceUrl, 'used_slots' => is_array($usedSlots) ? $usedSlots : [], ]), ])->save(); } $result['asset_id'] = $asset->id; $result['gallery_item'] = $this->formatImageGalleryItem($asset); $result['notice'] = 'Try On Outfit selesai dan hasilnya sudah masuk ke gallery image.'; } catch (\Throwable $e) { Log::warning('pollTryOn: DB save failed', ['error' => $e->getMessage()]); } } return response()->json($result); } public function uploadMediaAsset(Request $request, HuggingFaceMediaStorageService $hfMedia, OpenAiChatService $openAiChat): RedirectResponse { $data = $request->validate([ 'asset' => ['required', 'file', 'max:51200', 'mimetypes:image/jpeg,image/png,image/webp,image/gif,video/mp4,video/quicktime,video/webm'], 'label' => ['nullable', 'string', 'max:250'], ]); try { $file = $data['asset']; $mime = (string) $file->getMimeType(); $type = Str::startsWith($mime, 'video/') ? 'video' : 'image'; $ext = strtolower((string) $file->getClientOriginalExtension()); if ($ext === '') { $ext = $type === 'video' ? 'mp4' : 'jpg'; } $folder = $type === 'video' ? 'social_assets/videos' : 'social_assets/images'; $filename = now()->format('Ymd_His').'_'.$request->user()->id.'_'.Str::lower(Str::random(8)).'.'.$ext; $storedPath = $file->storeAs($folder, $filename, 'public'); $publicUrl = $hfMedia->offloadPublicRelativePath($storedPath); $manualLabel = trim((string) ($data['label'] ?? '')); $detectedPrompt = ''; if ($type === 'image') { try { $detectedPrompt = $openAiChat->describeImage($publicUrl); } catch (\Throwable $visionError) { report($visionError); } } $storedPrompt = $type === 'image' ? ($detectedPrompt !== '' ? $detectedPrompt : ($manualLabel !== '' ? $manualLabel : 'Uploaded image')) : $manualLabel; SocialMediaAsset::query()->create([ 'user_id' => $request->user()->id, 'type' => $type, 'provider' => 'upload', 'prompt' => $storedPrompt, 'url' => $publicUrl, 'thumbnail_url' => $type === 'image' ? $publicUrl : null, 'meta' => [ 'source' => 'tools_upload', 'mime' => $mime, 'original_name' => $file->getClientOriginalName(), 'size_bytes' => $file->getSize(), 'manual_label' => $manualLabel, 'ai_detected_prompt' => $detectedPrompt, ], ]); return redirect()->route('tools.index')->with('tool_notice', ucfirst($type).' berhasil diupload ke gallery AI Tools.'); } catch (\Throwable $e) { return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } public function remixMedia(Request $request, SocialMediaStudioService $studio): RedirectResponse { $data = $request->validate([ 'source_asset_id' => ['required', 'integer'], 'mode' => ['required', 'in:mockup,face_swap,strict_face_swap,restyle'], 'output_type' => ['required', 'in:image,video'], 'prompt' => ['required', 'string', 'max:2000'], ]); $source = SocialMediaAsset::query() ->where('id', $data['source_asset_id']) ->where('user_id', $request->user()->id) ->first(); if (! $source) { return redirect()->route('tools.index')->with('tool_error', 'Source asset tidak ditemukan atau bukan milik akun ini.')->withInput(); } try { $media = $studio->remixFromAsset([ 'type' => $source->type, 'url' => $source->url, 'thumbnail_url' => $source->thumbnail_url, ], $data['prompt'], $data['mode'], $data['output_type']); if ($data['output_type'] === 'video') { $saved = $this->saveGeneratedVideoAsset($request->user()->id, $media); return redirect()->route('tools.index')->with([ 'tool_notice' => 'Remix video berhasil dibuat dan disimpan ke gallery.', 'tool_remix_video_url' => $saved['url'] ?? null, 'tool_remix_mode' => $data['mode'], 'tool_remix_prompt' => $data['prompt'], 'tool_remix_source_id' => $source->id, ]); } $savedImages = $this->saveGeneratedImageAssets($request->user()->id, $media); $urls = collect($savedImages)->pluck('url')->filter()->values()->all(); return redirect()->route('tools.index')->with([ 'tool_notice' => 'Remix image berhasil dibuat dan disimpan ke gallery.', 'tool_image_urls' => $urls, 'tool_image_url' => $urls[0] ?? null, 'tool_image_prompt' => $data['prompt'], 'tool_image_aspect' => 'portrait', 'tool_image_count' => max(1, count($urls)), 'tool_image_provider' => (string) ($media['provider'] ?? 'remix'), 'tool_image_provider_selected' => 'auto', 'tool_remix_mode' => $data['mode'], 'tool_remix_prompt' => $data['prompt'], 'tool_remix_source_id' => $source->id, ]); } catch (\Throwable $e) { return redirect()->route('tools.index')->with('tool_error', $e->getMessage())->withInput(); } } public function remixStockAsset( Request $request, FreepikStockAssetService $stockAssets, SocialMediaStudioService $studio ): RedirectResponse { if ($this->isHfOnlyMode()) { return redirect()->route('tools.index') ->with('tool_error', 'HF-only mode aktif: fitur Remix dari Freepik stock tidak tersedia. ' .'Gunakan “Remix Lab” dengan asset yang Anda upload sendiri atau pilih “Gallery Assets”.'); } $data = $request->validate([ 'asset_kind' => ['required', 'in:images,videos,icons'], 'asset_id' => ['required', 'integer', 'min:1'], 'mode' => ['required', 'in:mockup,face_swap,strict_face_swap,restyle'], 'output_type' => ['required', 'in:image,video'], 'prompt' => ['required', 'string', 'max:2000'], ]); try { $imported = $stockAssets->importForEdit($data['asset_kind'], (int) $data['asset_id']); $sourceAsset = $this->createImportedStockAsset($request->user()->id, $imported); $media = $studio->remixFromAsset([ 'type' => $sourceAsset->type, 'url' => $sourceAsset->url, 'thumbnail_url' => $sourceAsset->thumbnail_url, ], $data['prompt'], $data['mode'], $data['output_type']); if ($data['output_type'] === 'video') { $saved = $this->saveGeneratedVideoAsset($request->user()->id, $media); return redirect()->route('tools.index', ['stock_q' => $request->input('stock_q')]) ->with([ 'tool_notice' => 'Stock Freepik berhasil diimpor ke project dan diproses jadi video AI.', 'tool_remix_video_url' => $saved['url'] ?? null, 'tool_remix_mode' => $data['mode'], 'tool_remix_prompt' => $data['prompt'], 'tool_remix_source_id' => $sourceAsset->id, ]); } $savedImages = $this->saveGeneratedImageAssets($request->user()->id, $media); $urls = collect($savedImages)->pluck('url')->filter()->values()->all(); return redirect()->route('tools.index', ['stock_q' => $request->input('stock_q')]) ->with([ 'tool_notice' => 'Stock Freepik berhasil diimpor ke project dan diproses jadi image AI.', 'tool_image_urls' => $urls, 'tool_image_url' => $urls[0] ?? null, 'tool_image_prompt' => $data['prompt'], 'tool_image_aspect' => 'portrait', 'tool_image_count' => max(1, count($urls)), 'tool_image_provider' => (string) ($media['provider'] ?? 'remix'), 'tool_image_provider_selected' => 'auto', 'tool_remix_mode' => $data['mode'], 'tool_remix_prompt' => $data['prompt'], 'tool_remix_source_id' => $sourceAsset->id, ]); } catch (\Throwable $e) { return redirect()->route('tools.index', [ 'stock_q' => $request->input('stock_q'), 'stock_type' => $request->input('stock_type', 'all'), 'stock_order' => $request->input('stock_order', 'relevance'), ])->with('tool_error', $e->getMessage())->withInput(); } } public function downloadStockAsset(Request $request, FreepikStockAssetService $stockAssets): BinaryFileResponse|RedirectResponse { if ($this->isHfOnlyMode()) { return redirect()->route('tools.index') ->with('tool_error', 'HF-only mode aktif. Download stock Freepik dimatikan.'); } $data = $request->validate([ 'asset_kind' => ['required', 'in:images,videos,icons,audio'], 'asset_id' => ['required', 'integer', 'min:1'], ]); try { $download = $stockAssets->downloadToProject($data['asset_kind'], (int) $data['asset_id']); return response()->download( $download['path'], $download['filename'], [ 'Content-Type' => $download['mime'] ?? 'application/octet-stream', ] ); } catch (\Throwable $e) { return redirect()->route('tools.index', [ 'stock_q' => $request->input('stock_q'), 'stock_type' => $request->input('stock_type', 'all'), 'stock_order' => $request->input('stock_order', 'relevance'), ])->with('tool_error', $e->getMessage()); } } public function destroyMediaAsset(Request $request, SocialMediaAsset $asset): RedirectResponse { if ((int) $asset->user_id !== (int) $request->user()->id) { abort(403); } $this->deletePublicUrlFile($asset->url); $this->deletePublicUrlFile((string) $asset->thumbnail_url); $asset->delete(); return redirect()->route('tools.index')->with('tool_notice', 'Asset berhasil dihapus dari gallery.'); } private function generateFromProvider( string $provider, string $prompt, string $aspect, int $imageCount, HuggingFaceImageService $hfImage, PollinationsImageService $pollinations, ?ReplicateImageService $replicateImage = null, array $referenceImages = [] ): array { if (in_array($provider, ['replicate-imagen4', 'replicate-flux', 'replicate-ideogram', 'replicate-flux-flex'], true)) { if ($replicateImage === null || ! $replicateImage->isAvailable()) { throw new \RuntimeException('Replicate API key belum diset. Tambahkan REPLICATE_API_KEY di Settings.'); } return $replicateImage->generate($prompt, $aspect, $imageCount, $provider, $referenceImages); } return match ($provider) { 'freepik', 'openai', 'huggingface' => $hfImage->generate($prompt, $aspect, $imageCount), 'pollinations' => $pollinations->generate($prompt, $aspect, $imageCount), default => throw new \RuntimeException('Provider tidak valid.'), }; } private function redirectWithImageResult( array $imageUrls, string $prompt, string $aspect, int $imageCount, string $provider, ?string $notice = null, ?string $selectedProvider = null, ?int $userId = null ): RedirectResponse { $imageUrls = $this->normalizeGeneratedImageUrls($imageUrls); if ($imageUrls === []) { return redirect()->route('app.index')->with([ 'tool_error' => 'Generate image selesai, tapi tidak ada URL hasil yang valid untuk ditampilkan. Coba generate ulang sekali lagi.', 'tool_image_prompt' => $prompt, 'tool_image_aspect' => $aspect, 'tool_image_count' => $imageCount, 'tool_image_provider' => $provider, 'tool_image_provider_selected' => $selectedProvider ?? $provider, ]); } $assetIds = []; if ($userId !== null && ! empty($imageUrls)) { try { $assetIds = $this->saveImageUrlsToGallery($userId, $imageUrls, $prompt, $aspect, $provider); } catch (\Throwable $error) { Log::warning('Gagal menyimpan hasil generate image ke gallery.', [ 'provider' => $provider, 'message' => $error->getMessage(), ]); } } $payload = [ 'tool_image_urls' => $imageUrls, 'tool_image_url' => $imageUrls[0] ?? null, 'tool_image_prompt' => $prompt, 'tool_image_aspect' => $aspect, 'tool_image_count' => $imageCount, 'tool_image_provider' => $provider, 'tool_image_provider_selected' => $selectedProvider ?? $provider, ]; if (! empty($assetIds)) { $payload['tool_image_asset_ids'] = $assetIds; } if ($notice) { $payload['tool_notice'] = $notice; } return redirect()->route('app.index')->with($payload); } private function saveImageUrlsToGallery(int $userId, array $urls, string $prompt, string $aspect, string $provider): array { $storedPrompt = trim((string) Str::of($prompt)->limit(1000, '')); $ids = []; foreach ($urls as $url) { $cleanUrl = trim((string) $url); if ($cleanUrl === '') { continue; } $asset = SocialMediaAsset::query()->create([ 'user_id' => $userId, 'type' => 'image', 'provider' => $provider, 'prompt' => $storedPrompt, 'url' => $cleanUrl, 'thumbnail_url' => $cleanUrl, 'meta' => ['aspect' => $aspect, 'source' => 'tools_generate_image'], ]); $ids[] = $asset->id; } return $ids; } private function storeTemporaryToolImage($file, int $userId, string $tag, HuggingFaceMediaStorageService $hfMedia): string { $extension = strtolower((string) $file->getClientOriginalExtension()) ?: 'jpg'; $stored = $file->storeAs( 'social_assets/images', now()->format('Ymd_His').'_'.$userId.'_'.$tag.'_'.Str::lower(Str::random(8)).'.'.$extension, 'public' ); return trim((string) $hfMedia->offloadPublicRelativePath($stored)); } private function augmentImagePromptWithTemplateReferences( Request $request, string $prompt, int $userId, HuggingFaceMediaStorageService $hfMedia ): array { $basePrompt = trim($prompt); $templateMode = trim((string) $request->input('card_template_type', '')); $referenceConfigs = [ [ 'field' => 'template_reference_file_1', 'tag' => 'id_card_template', 'label' => 'ID card template', 'mode' => 'employee-id', ], [ 'field' => 'template_reference_file_2', 'tag' => 'business_card_template', 'label' => 'business card template', 'mode' => 'business-name', ], ]; $referenceCues = []; $referenceUrls = []; foreach ($referenceConfigs as $config) { $file = $request->file($config['field']); if (! $file) { continue; } try { $publicUrl = $this->storeTemporaryToolImage( $file, $userId, $config['tag'], $hfMedia ); } catch (\Throwable $storageError) { report($storageError); continue; } $referenceUrls[] = $publicUrl; $description = $this->fallbackTemplateReferenceDescription( (string) $file->getClientOriginalName(), (string) $config['label'] ); $prefix = $templateMode !== '' && $templateMode === $config['mode'] ? 'Primary template reference' : 'Additional template reference'; $referenceCues[] = $prefix.' from '.$config['label'].': '.$description.'.'; } if ($referenceCues === []) { return [ 'prompt' => $basePrompt, 'urls' => [], ]; } return [ 'prompt' => trim($basePrompt.' Use the uploaded card template references as visual guidance for composition, spacing, typography hierarchy, content zones, border treatment, and overall print-ready presentation. '.implode(' ', $referenceCues).' Keep all requested names, titles, company labels, and ID/contact details readable, intentional, and clean.'), 'urls' => array_values(array_unique(array_filter($referenceUrls))), ]; } private function fallbackTemplateReferenceDescription(string $originalName, string $label): string { $cleanName = trim((string) Str::of(pathinfo($originalName, PATHINFO_FILENAME)) ->replace(['_', '-'], ' ') ->squish() ->limit(80, '...')); if ($cleanName === '') { return 'clean professional '.$label.' layout cues'; } return 'design cues inspired by uploaded '.$label.' file named '.$cleanName; } private function decodeTryOnLayout(string $layoutJson): array { $decoded = json_decode(trim($layoutJson), true); if (! is_array($decoded)) { return []; } return collect($decoded) ->filter(fn ($item) => is_array($item)) ->values() ->all(); } private function buildReplicateTryOnInputs( Request $request, array $uploadedOutfitSlots, array $layout, string $aiBrief, int $userId, HuggingFaceMediaStorageService $hfMedia ): array { $layoutBySlot = collect($layout) ->filter(fn ($item) => is_array($item) && filled($item['slot'] ?? null)) ->keyBy(fn ($item) => (int) ($item['slot'] ?? 0)); $slotPlans = collect($uploadedOutfitSlots) ->map(function (int $slot) use ($request, $layoutBySlot, $aiBrief, $uploadedOutfitSlots) { $file = $request->file('outfit_file_'.$slot); $layoutItem = $layoutBySlot->get($slot, []); $placement = (string) ($layoutItem['placement'] ?? ''); $originalName = $file ? (string) $file->getClientOriginalName() : ''; return [ 'slot' => $slot, 'file' => $file, 'placement' => $placement, 'type' => $this->inferReplicateTryOnType($originalName, $placement, $aiBrief, count($uploadedOutfitSlots)), 'y' => (float) ($layoutItem['y'] ?? 0.5), ]; }) ->filter(fn ($item) => $item['file'] !== null) ->sortBy('y') ->values(); $inputs = []; $usedSlots = []; $primarySlot = null; foreach ($slotPlans as $plan) { $field = $this->resolveReplicateTryOnInputField((string) $plan['type'], array_keys($inputs)); if ($field === null) { continue; } $url = $this->storeTemporaryToolImage( $plan['file'], $userId, 'tryon_outfit_'.$plan['slot'], $hfMedia ); $inputs[$field] = $url; $usedSlots[] = [ 'slot' => (int) $plan['slot'], 'field' => $field, 'placement' => (string) $plan['placement'], ]; if ($primarySlot === null) { $primarySlot = (int) $plan['slot']; } } if ($inputs === []) { throw new \RuntimeException('Reference outfit belum berhasil dipetakan ke tipe pakaian Replicate.'); } return [$inputs, $usedSlots, $primarySlot ?? (int) ($uploadedOutfitSlots[0] ?? 1)]; } private function inferReplicateTryOnType(string $originalName, string $placement, string $aiBrief, int $uploadedCount): string { $haystack = Str::lower(trim($originalName.' '.$placement.' '.$aiBrief)); if (Str::contains($haystack, ['dress', 'gown', 'robe', 'gamis', 'abaya'])) { return 'dress'; } if (Str::contains($haystack, ['pants', 'trouser', 'jeans', 'skirt', 'shorts', 'celana', 'rok'])) { return 'bottom'; } if (Str::contains($haystack, ['jacket', 'coat', 'blazer', 'cardigan', 'hoodie', 'outer', 'mantel', 'jas'])) { return 'outer'; } if (Str::contains($haystack, ['shirt', 'top', 'blouse', 'tee', 'tshirt', 'sweater', 'hijab', 'jilbab', 'kerudung', 'scarf'])) { return 'top'; } if (Str::contains($haystack, ['lower'])) { return 'bottom'; } if (Str::contains($haystack, ['center area']) && $uploadedCount === 1) { return 'dress'; } return 'top'; } private function resolveReplicateTryOnInputField(string $type, array $existingFields): ?string { $primaryField = match ($type) { 'bottom' => 'bottom_image', 'outer' => 'outer_image', 'dress' => 'dress_image', default => 'top_image', }; if (! in_array($primaryField, $existingFields, true)) { return $primaryField; } foreach (['top_image', 'outer_image', 'bottom_image', 'dress_image'] as $fallbackField) { if (! in_array($fallbackField, $existingFields, true)) { return $fallbackField; } } return null; } private function saveGeneratedImageAsset(int $userId, array $media): array { $url = $this->offloadGeneratedImageUrl((string) ($media['url'] ?? '')); $thumb = $this->offloadGeneratedImageUrl((string) ($media['thumbnail_url'] ?? $media['url'] ?? '')); if (blank($url)) { throw new \RuntimeException('Image URL kosong, jadi hasil poster belum bisa disimpan ke gallery.'); } $asset = SocialMediaAsset::query()->create([ 'user_id' => $userId, 'type' => 'image', 'provider' => (string) ($media['provider'] ?? 'unknown'), 'prompt' => (string) ($media['prompt'] ?? ''), 'url' => $url, 'thumbnail_url' => $thumb, 'width' => (int) ($media['width'] ?? 1080), 'height' => (int) ($media['height'] ?? 1920), 'meta' => array_merge([ 'aspect' => $media['aspect'] ?? '9:16', 'source' => 'tools_remix', ], is_array($media['meta'] ?? null) ? $media['meta'] : []), ]); return $this->formatImageGalleryItem($asset); } private function saveGeneratedImageAssets(int $userId, array $media): array { $variants = collect($media['variants'] ?? []) ->filter(fn ($item) => is_array($item) && filled($item['url'] ?? null)) ->values(); if ($variants->isEmpty() && filled($media['url'] ?? null)) { $variants = collect([[ 'url' => (string) $media['url'], 'thumbnail_url' => (string) ($media['thumbnail_url'] ?? $media['url']), 'width' => (int) ($media['width'] ?? 1080), 'height' => (int) ($media['height'] ?? 1920), ]]); } return $variants->map(function (array $variant, int $index) use ($userId, $media) { $url = $this->offloadGeneratedImageUrl((string) $variant['url']); $thumb = $this->offloadGeneratedImageUrl((string) ($variant['thumbnail_url'] ?? $variant['url'])); $asset = SocialMediaAsset::query()->create([ 'user_id' => $userId, 'type' => 'image', 'provider' => (string) ($media['provider'] ?? 'unknown'), 'prompt' => (string) ($media['prompt'] ?? ''), 'url' => $url, 'thumbnail_url' => $thumb, 'width' => (int) ($variant['width'] ?? $media['width'] ?? 1080), 'height' => (int) ($variant['height'] ?? $media['height'] ?? 1920), 'meta' => [ 'aspect' => $media['aspect'] ?? '9:16', 'variant_index' => $index, 'source' => 'tools_remix', ], ]); return [ 'id' => $asset->id, 'type' => 'image', 'url' => $asset->url, 'thumbnail_url' => $asset->thumbnail_url, ]; })->all(); } private function offloadGeneratedImageUrl(string $url): string { $cleanUrl = trim($url); if ($cleanUrl === '') { return ''; } $relativePath = $this->extractPublicStorageRelativePath($cleanUrl); if ($relativePath === null) { return $cleanUrl; } /** @var HuggingFaceMediaStorageService $hfMedia */ $hfMedia = app(HuggingFaceMediaStorageService::class); $offloadedUrl = trim((string) $hfMedia->offloadPublicRelativePath($relativePath)); if ($offloadedUrl === '' || $this->extractPublicStorageRelativePath($offloadedUrl) !== null) { throw new \RuntimeException('Image hasil generate belum berhasil dioffload ke Hugging Face bucket.'); } return $offloadedUrl; } private function normalizeGeneratedImageUrls(array $urls): array { return collect($urls) ->map(function ($url) { $cleanUrl = trim((string) $url); if ($cleanUrl === '') { return null; } try { return $this->offloadGeneratedImageUrl($cleanUrl); } catch (\Throwable $error) { \Illuminate\Support\Facades\Log::warning('Image result URL tetap memakai public storage URL.', [ 'url' => $cleanUrl, 'error' => $error->getMessage(), ]); return $cleanUrl; } }) ->filter(fn ($url) => is_string($url) && trim($url) !== '') ->values() ->all(); } private function extractPublicStorageRelativePath(string $url): ?string { $cleanUrl = trim($url); if ($cleanUrl === '') { return null; } $path = parse_url($cleanUrl, PHP_URL_PATH); $candidate = is_string($path) && $path !== '' ? $path : $cleanUrl; if (! str_starts_with($candidate, '/storage/')) { return null; } $relativePath = ltrim(substr($candidate, strlen('/storage/')), '/'); return $relativePath !== '' ? $relativePath : null; } private function saveGeneratedVideoAsset(int $userId, array $media): array { if (blank($media['url'] ?? null)) { throw new \RuntimeException('Video URL kosong, jadi belum bisa disimpan ke gallery.'); } $asset = SocialMediaAsset::query()->create([ 'user_id' => $userId, 'type' => 'video', 'provider' => (string) ($media['provider'] ?? 'unknown'), 'prompt' => (string) ($media['prompt'] ?? ''), 'url' => (string) ($media['url'] ?? ''), 'thumbnail_url' => (string) ($media['source_image_url'] ?? ''), 'width' => 1080, 'height' => 1920, 'duration_seconds' => (int) ($media['duration_seconds'] ?? 0), 'meta' => [ 'aspect' => $media['aspect'] ?? '9:16', 'source' => 'tools_remix', ], ]); return $this->formatVideoGalleryItem($asset); } private function formatVideoGalleryItem(SocialMediaAsset $asset): array { return [ 'id' => $asset->id, 'type' => 'video', 'provider' => (string) $asset->provider, 'prompt' => (string) ($asset->prompt ?? ''), 'display_prompt' => $this->cleanAssetPrompt($asset->prompt, 'Generated video'), 'url' => (string) $asset->url, 'thumb' => (string) ($asset->thumbnail_url ?: $asset->url), 'created_at' => optional($asset->created_at)?->diffForHumans() ?? 'Baru saja', ]; } private function formatImageGalleryItem(SocialMediaAsset $asset): array { return [ 'id' => $asset->id, 'type' => 'image', 'provider' => (string) $asset->provider, 'prompt' => (string) ($asset->prompt ?? ''), 'display_prompt' => $this->cleanAssetPrompt($asset->prompt, 'Generated image'), 'url' => (string) $asset->url, 'thumb' => (string) ($asset->thumbnail_url ?: $asset->url), 'created_at' => optional($asset->created_at)?->diffForHumans() ?? 'Baru saja', ]; } private function moviePosterGenres(): array { return [ 'action' => [ 'label' => 'Action', 'direction' => 'Turn the subject into the fearless lead of a high-stakes action blockbuster with intense cinematic lighting, explosive energy, dramatic city backdrop, premium poster layout, bold title area, and theatrical finishing.', ], 'romance' => [ 'label' => 'Romance', 'direction' => 'Turn the subject into the lead of an emotional romance movie poster with cinematic sunset light, tender expression, elegant styling, dreamy background depth, premium title area, and polished theatrical poster composition.', ], 'super-hero' => [ 'label' => 'Super Hero', 'direction' => 'Turn the subject into the main hero of a premium superhero movie poster with iconic costume cues, dramatic rim light, epic scale, powerful stance, atmospheric background, and bold blockbuster typography space.', ], 'horror' => [ 'label' => 'Horror', 'direction' => 'Turn the subject into the lead of a chilling horror movie poster with eerie atmosphere, moody shadows, haunting cinematic lighting, unsettling background details, premium poster composition, and restrained theatrical typography space.', ], 'science-fiction' => [ 'label' => 'Science Fiction', 'direction' => 'Turn the subject into the protagonist of a futuristic science-fiction movie poster with high-end production design, neon atmospheric light, cinematic scale, advanced worldbuilding cues, and polished theatrical poster composition.', ], 'crime' => [ 'label' => 'Crime', 'direction' => 'Turn the subject into the lead of a gritty crime thriller poster with urban night mood, tense expression, noir-inspired lighting, cinematic background layering, premium title area, and dramatic theatrical finish.', ], ]; } private function composeMoviePosterPrompt(string $genre, string $customBrief = ''): string { $genres = $this->moviePosterGenres(); $selected = $genres[$genre] ?? $genres['action']; $customInstruction = $customBrief !== '' ? 'Additional creative direction: '.$customBrief.'.' : ''; return trim($selected['direction'].' Preserve the subject identity, facial likeness, and hero framing from the reference portrait. Create one strong vertical key art frame with premium color grading, readable negative space for movie title, subtle credits block styling, and no duplicated people. '.$customInstruction); } private function cleanAssetPrompt(?string $text, string $fallback): string { $value = trim((string) $text); if ($value === '') { return $fallback; } $value = preg_replace('/Imported from Replicate prediction\s+/i', '', $value); $value = preg_replace('/\b[a-z0-9]{18,}\b/i', '', $value); $value = preg_replace('/\s{2,}/', ' ', $value); $value = trim((string) $value, " -\t\n\r\0\x0B."); return $value !== '' ? $value : $fallback; } private function shouldReturnJson(Request $request): bool { return $request->expectsJson() || $request->ajax() || str_contains(strtolower((string) $request->header('Accept', '')), 'application/json'); } private function createImportedStockAsset(int $userId, array $imported): SocialMediaAsset { return SocialMediaAsset::query()->create([ 'user_id' => $userId, 'type' => (string) ($imported['type'] ?? 'image'), 'provider' => (string) ($imported['provider'] ?? 'freepik-stock'), 'prompt' => (string) ($imported['title'] ?? 'Freepik stock asset'), 'url' => (string) ($imported['url'] ?? ''), 'thumbnail_url' => (string) ($imported['thumbnail_url'] ?? $imported['url'] ?? ''), 'width' => $imported['width'] ?? null, 'height' => $imported['height'] ?? null, 'duration_seconds' => $imported['duration_seconds'] ?? null, 'meta' => [ 'source' => 'tools_freepik_stock', 'kind' => $imported['kind'] ?? null, 'remote_id' => $imported['remote_id'] ?? null, ], ]); } private function deletePublicUrlFile(?string $url): void { $url = trim((string) $url); if ($url === '' || ! Str::startsWith($url, '/storage/')) { return; } $relativePath = ltrim(Str::replaceFirst('/storage/', '', $url), '/'); if ($relativePath !== '' && Storage::disk('public')->exists($relativePath)) { Storage::disk('public')->delete($relativePath); } } private function shouldFallbackToPollinations(\Throwable $e): bool { $message = Str::lower($e->getMessage()); return Str::contains($message, [ 'openai_api_key belum diisi', 'does not have access to model', 'organization verification', 'openai image request gagal: 401', 'openai image request gagal: 403', 'openai image request gagal: 404', 'openai image request gagal: 429', 'openai image request gagal: 500', 'openai image request gagal: 502', 'openai image request gagal: 503', ]) || $this->isProviderUnavailable($e); } private function isProviderUnavailable(\Throwable $e): bool { $message = Str::lower($e->getMessage()); return Str::contains($message, [ '402', 'depleted', 'credits', 'quota', 'rate limit', 'rate_limit', 'too many requests', 'hf_api_token belum diisi', 'hugging face image request gagal: 401', 'hugging face image request gagal: 403', 'hugging face image request gagal: 429', 'hugging face image request gagal: 402', 'hugging face image request gagal: 500', 'hugging face image request gagal: 503', ]); } private function isHfOnlyMode(): bool { return true; } private function normalizeImageProvider(string $provider): string { if ($this->isHfOnlyMode() && $provider === 'freepik') { return 'huggingface'; } return $provider; } public function enhanceImage(Request $request, ReplicateEnhanceService $enhance, HuggingFaceMediaStorageService $hfMedia, OpenAiChatService $openAiChat): RedirectResponse { $data = $request->validate([ 'source_asset_id' => ['nullable', 'integer'], 'source_image_file' => ['nullable', 'file', 'max:20480', 'mimetypes:image/jpeg,image/png,image/webp'], 'enhance_mode' => ['required', 'in:upscale,face_restore,portrait_enhance'], 'upscale_factor' => ['nullable', 'in:2,4,6'], 'fidelity' => ['nullable', 'numeric', 'min:0', 'max:1'], ]); try { if (! $enhance->isAvailable()) { throw new \RuntimeException('REPLICATE_API_KEY belum diset.'); } // Resolve source image URL $sourceUrl = null; if (! empty($data['source_asset_id'])) { $asset = SocialMediaAsset::where('id', $data['source_asset_id']) ->where('user_id', $request->user()->id) ->where('type', 'image') ->first(); if ($asset) { $sourceUrl = $asset->url; } } if (blank($sourceUrl) && $request->hasFile('source_image_file')) { $file = $request->file('source_image_file'); $ext = strtolower((string) $file->getClientOriginalExtension()) ?: 'jpg'; $stored = $file->storeAs( 'social_assets/images', now()->format('Ymd_His').'_'.$request->user()->id.'_enhance_'.Str::lower(Str::random(8)).'.'.$ext, 'public' ); $sourceUrl = $hfMedia->offloadPublicRelativePath($stored); $detectedPrompt = ''; try { $detectedPrompt = $openAiChat->describeImage($sourceUrl); } catch (\Throwable $visionError) { report($visionError); } SocialMediaAsset::query()->create([ 'user_id' => $request->user()->id, 'type' => 'image', 'provider' => 'upload', 'prompt' => $detectedPrompt !== '' ? $detectedPrompt : 'Uploaded image for enhance', 'url' => $sourceUrl, 'thumbnail_url' => $sourceUrl, 'meta' => [ 'source' => 'enhance_upload', 'ai_detected_prompt' => $detectedPrompt, ], ]); } if (blank($sourceUrl)) { throw new \RuntimeException('Pilih gambar sumber atau upload file gambar terlebih dahulu.'); } $outputUrl = match ($data['enhance_mode']) { 'upscale' => $enhance->upscaleImage($sourceUrl, (int) ($data['upscale_factor'] ?? 2)), 'face_restore' => $enhance->restoreFace($sourceUrl, (float) ($data['fidelity'] ?? 0.7)), 'portrait_enhance' => $enhance->portraitEnhance( $sourceUrl, (int) ($data['upscale_factor'] ?? 2), (float) ($data['fidelity'] ?? 0.72) ), }; $provider = match ($data['enhance_mode']) { 'upscale' => 'replicate-upscale', 'face_restore' => 'replicate-codeformer', default => 'replicate-portrait-enhance', }; $notice = match ($data['enhance_mode']) { 'upscale' => 'Gambar berhasil di-upscale dengan Replicate AI.', 'face_restore' => 'Wajah berhasil direstore dengan Replicate AI.', default => 'Portrait enhance selesai: upscale dan face restore sudah diterapkan.', }; $sourcePrompt = SocialMediaAsset::query() ->where('user_id', $request->user()->id) ->where('type', 'image') ->where('url', $sourceUrl) ->value('prompt'); $enhancedPrompt = trim((string) $sourcePrompt) !== '' ? trim((string) $sourcePrompt) : 'Enhanced image result'; // Save to gallery SocialMediaAsset::query()->create([ 'user_id' => $request->user()->id, 'type' => 'image', 'provider' => $provider, 'prompt' => $enhancedPrompt, 'url' => $outputUrl, 'thumbnail_url' => $outputUrl, 'meta' => [ 'source' => 'enhance', 'source_url' => $sourceUrl, 'mode' => $data['enhance_mode'], 'upscale_factor' => (string) ($data['upscale_factor'] ?? '2'), ], ]); return redirect()->route('tools.index')->with([ 'tool_image_urls' => [$outputUrl], 'tool_image_url' => $outputUrl, 'tool_image_provider' => $provider, 'tool_enhance_before_url' => $sourceUrl, 'tool_enhance_after_url' => $outputUrl, 'tool_enhance_mode' => $data['enhance_mode'], 'tool_enhance_factor' => (string) ($data['upscale_factor'] ?? '2'), 'tool_notice' => $notice, ]); } catch (\Throwable $e) { return redirect()->route('tools.index') ->with('tool_error', $e->getMessage()) ->withInput(); } } }