diff --git a/app/Helpers/FirstParty/AI/RunwareAI.php b/app/Helpers/FirstParty/AI/RunwareAI.php new file mode 100644 index 0000000..cbc8ac5 --- /dev/null +++ b/app/Helpers/FirstParty/AI/RunwareAI.php @@ -0,0 +1,60 @@ +withHeaders([ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $api_key, + ]) + ->post('https://api.runware.ai/v1', [ + [ + "taskUUID" => $uuid, + "taskType" => "imageInference", + "width" => $width, + "height" => $height, + "numberResults" => 1, + "outputFormat" => "WEBP", + "outputType" => ["URL"], + "includeCost" => true, + "inputImages" => [], + "positivePrompt" => $prompt, + "model" => "runware:100@1" + ] + ]); + + // Check if the request was successful + if ($response->successful()) { + $data = $response->json(); + + // Extract the image URL from the response + if (isset($data['data']) && count($data['data']) > 0) { + $imageData = collect($data['data']) + ->where('taskType', 'imageInference') + ->first(); + + if ($imageData && isset($imageData['imageURL'])) { + return $imageData['imageURL']; + } + } + + throw new \Exception('Image URL not found in response'); + } + + throw new \Exception('API request failed: ' . $response->status() . ' - ' . $response->body()); + } catch (\Exception $e) { + // Log the error or handle as needed + \Log::error('RunwareAI API Error: ' . $e->getMessage()); + throw $e; + } + } +} diff --git a/app/Helpers/FirstParty/Maintenance/MemeMediaMaintenance.php b/app/Helpers/FirstParty/Maintenance/MemeMediaMaintenance.php index 3c7865b..7af4e27 100644 --- a/app/Helpers/FirstParty/Maintenance/MemeMediaMaintenance.php +++ b/app/Helpers/FirstParty/Maintenance/MemeMediaMaintenance.php @@ -23,7 +23,7 @@ public static function populateDurations() } } - private static function getDurationUsingFfmpeg($meme_media) + public static function getDurationUsingFfmpeg($meme_media) { $duration_milliseconds = FFMpeg::openUrl($meme_media->webm_url)->getDurationInMiliseconds(); $duration_seconds = ($duration_milliseconds / 1000); diff --git a/app/Http/Controllers/TestController.php b/app/Http/Controllers/TestController.php index 66f8bcf..931c8db 100644 --- a/app/Http/Controllers/TestController.php +++ b/app/Http/Controllers/TestController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Helpers\FirstParty\AI\OpenAI; +use App\Helpers\FirstParty\AI\RunwareAI; +use Str; class TestController extends Controller { @@ -24,4 +26,12 @@ public function writeMeme() dd($meme_output); } + + public function generateSchnellImage() + { + $uuid = Str::uuid(); + $image_url = RunwareAI::generateSchnellImage($uuid, 'office desk cluttered with papers and a computer', 1024, 1024); + + dd($image_url); + } } diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..a57f1f1 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,83 @@ + Vector::class, + '_lft' => 'int', + '_rgt' => 'int', + 'parent_id' => 'int', + 'subcategories' => 'object', + 'meme_angles' => 'array', + 'sample_captions' => 'array', + 'keywords' => 'array', + 'payload' => 'object', + ]; + + protected $fillable = [ + 'name', + 'slug', + 'description', + '_lft', + '_rgt', + 'parent_id', + 'subcategories', + 'meme_angles', + 'sample_captions', + 'keywords', + 'payload' + ]; + + /** + * Scope to get only main categories + */ + public function scopeMainCategories($query) + { + return $query->whereNull('parent_id'); + } + + /** + * Scope to get only subcategories + */ + public function scopeSubcategories($query) + { + return $query->whereNotNull('parent_id'); + } +} diff --git a/app/Models/MemeMedia.php b/app/Models/MemeMedia.php index e46a1cf..78f8432 100644 --- a/app/Models/MemeMedia.php +++ b/app/Models/MemeMedia.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Pgvector\Laravel\HasNeighbors; use Pgvector\Laravel\Vector; +use Spatie\Tags\HasTags; /** * Class MemeMedia @@ -33,13 +34,15 @@ */ class MemeMedia extends Model { - use HasNeighbors, SoftDeletes; + use HasNeighbors, SoftDeletes, HasTags; protected $table = 'meme_medias'; protected $casts = [ 'embedding' => Vector::class, 'duration' => 'double', + 'keywords' => 'array', + 'is_enabled' => 'boolean', ]; protected $fillable = [ @@ -85,7 +88,7 @@ class MemeMedia extends Model protected function ids(): Attribute { return Attribute::make( - get: fn ($value, $attributes) => hashids_encode($attributes['id']), + get: fn($value, $attributes) => hashids_encode($attributes['id']), ); } } diff --git a/composer.json b/composer.json index ecfdb1d..7eb54d4 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "artesaos/seotools": "^1.3", "inertiajs/inertia-laravel": "^2.0", + "kalnoy/nestedset": "^6.0", "laravel/framework": "^12.0", "laravel/horizon": "^5.31", "laravel/sanctum": "^4.0", @@ -20,6 +21,7 @@ "pbmedia/laravel-ffmpeg": "^8.7", "pgvector/pgvector": "^0.2.2", "spatie/laravel-responsecache": "^7.7", + "spatie/laravel-tags": "^4.10", "tightenco/ziggy": "^2.4", "vinkla/hashids": "^13.0" }, diff --git a/composer.lock b/composer.lock index d1c230d..a79888e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fe17bc6263907633c97c7d1670d002ba", + "content-hash": "79e2a426d2f3fc1237920417f93984c5", "packages": [ { "name": "artesaos/seotools", @@ -1530,6 +1530,69 @@ }, "time": "2025-04-10T15:08:36+00:00" }, + { + "name": "kalnoy/nestedset", + "version": "v6.0.6", + "source": { + "type": "git", + "url": "https://github.com/lazychaser/laravel-nestedset.git", + "reference": "3cfc56a9759fb592bc903056166bfc0867f9e679" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lazychaser/laravel-nestedset/zipball/3cfc56a9759fb592bc903056166bfc0867f9e679", + "reference": "3cfc56a9759fb592bc903056166bfc0867f9e679", + "shasum": "" + }, + "require": { + "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/events": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "7.*|8.*|9.*|^10.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Kalnoy\\Nestedset\\NestedSetServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "v5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kalnoy\\Nestedset\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alexander Kalnoy", + "email": "lazychaser@gmail.com" + } + ], + "description": "Nested Set Model for Laravel 5.7 and up", + "keywords": [ + "database", + "hierarchy", + "laravel", + "nested sets", + "nsm" + ], + "support": { + "issues": "https://github.com/lazychaser/laravel-nestedset/issues", + "source": "https://github.com/lazychaser/laravel-nestedset/tree/v6.0.6" + }, + "time": "2025-04-22T19:38:02+00:00" + }, { "name": "laravel/framework", "version": "v12.9.2", @@ -4301,6 +4364,80 @@ ], "time": "2024-04-27T21:32:50+00:00" }, + { + "name": "spatie/eloquent-sortable", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/eloquent-sortable.git", + "reference": "76c8fbc79e1d5eec85e7145e46c7f0a65e1f4cda" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/eloquent-sortable/zipball/76c8fbc79e1d5eec85e7145e46c7f0a65e1f4cda", + "reference": "76c8fbc79e1d5eec85e7145e46c7f0a65e1f4cda", + "shasum": "" + }, + "require": { + "illuminate/database": "^9.31|^10.0|^11.0|^12.0", + "illuminate/support": "^9.31|^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.63|^3.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.5|^10.0|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\EloquentSortable\\EloquentSortableServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\EloquentSortable\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be" + } + ], + "description": "Sortable behaviour for eloquent models", + "homepage": "https://github.com/spatie/eloquent-sortable", + "keywords": [ + "behaviour", + "eloquent", + "laravel", + "model", + "sort", + "sortable" + ], + "support": { + "issues": "https://github.com/spatie/eloquent-sortable/issues", + "source": "https://github.com/spatie/eloquent-sortable/tree/4.5.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-06-03T12:41:10+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.92.4", @@ -4445,6 +4582,159 @@ ], "time": "2025-05-20T08:39:19+00:00" }, + { + "name": "spatie/laravel-tags", + "version": "4.10.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-tags.git", + "reference": "9fc59a9328e892bbb5b01c948b0d703e22d543ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-tags/zipball/9fc59a9328e892bbb5b01c948b0d703e22d543ec", + "reference": "9fc59a9328e892bbb5b01c948b0d703e22d543ec", + "shasum": "" + }, + "require": { + "laravel/framework": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.63|^3.0", + "php": "^8.1", + "spatie/eloquent-sortable": "^4.0", + "spatie/laravel-package-tools": "^1.4", + "spatie/laravel-translatable": "^6.0" + }, + "require-dev": { + "orchestra/testbench": "^8.0|^9.0|^10.0", + "pestphp/pest": "^1.22|^2.0", + "phpunit/phpunit": "^9.5.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Tags\\TagsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\Tags\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Add tags and taggable behaviour to your Laravel app", + "homepage": "https://github.com/spatie/laravel-tags", + "keywords": [ + "laravel-tags", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-tags/issues", + "source": "https://github.com/spatie/laravel-tags/tree/4.10.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-03-08T07:49:06+00:00" + }, + { + "name": "spatie/laravel-translatable", + "version": "6.11.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-translatable.git", + "reference": "032d85b28de315310dab2048b857016f1194f68b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-translatable/zipball/032d85b28de315310dab2048b857016f1194f68b", + "reference": "032d85b28de315310dab2048b857016f1194f68b", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.0", + "spatie/laravel-package-tools": "^1.11" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.20|^2.0|^3.0" + }, + "type": "library", + "extra": { + "aliases": { + "Translatable": "Spatie\\Translatable\\Facades\\Translatable" + }, + "laravel": { + "providers": [ + "Spatie\\Translatable\\TranslatableServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\Translatable\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A trait to make an Eloquent model hold translations", + "homepage": "https://github.com/spatie/laravel-translatable", + "keywords": [ + "eloquent", + "i8n", + "laravel-translatable", + "model", + "multilingual", + "spatie", + "translate" + ], + "support": { + "issues": "https://github.com/spatie/laravel-translatable/issues", + "source": "https://github.com/spatie/laravel-translatable/tree/6.11.4" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-20T15:51:22+00:00" + }, { "name": "spatie/temporary-directory", "version": "2.3.0", diff --git a/config/services.php b/config/services.php index 31c2972..cf691f1 100644 --- a/config/services.php +++ b/config/services.php @@ -39,4 +39,8 @@ 'api_token' => env('REPLICATE_API_TOKEN'), ], + 'runware' => [ + 'api_key' => env('RUNWARE_API_KEY'), + ] + ]; diff --git a/config/tags.php b/config/tags.php new file mode 100644 index 0000000..628dc1a --- /dev/null +++ b/config/tags.php @@ -0,0 +1,28 @@ + null, + + /* + * The fully qualified class name of the tag model. + */ + 'tag_model' => Spatie\Tags\Tag::class, + + /* + * The name of the table associated with the taggable morph relation. + */ + 'taggable' => [ + 'table_name' => 'taggables', + 'morph_name' => 'taggable', + + /* + * The fully qualified class name of the pivot model. + */ + 'class_name' => Illuminate\Database\Eloquent\Relations\MorphPivot::class, + ] +]; diff --git a/database/migrations/2025_06_14_144414_add_duration_to_meme_medias_table.php b/database/migrations/2025_06_14_144414_add_duration_to_meme_medias_table.php deleted file mode 100644 index 7f1cb5a..0000000 --- a/database/migrations/2025_06_14_144414_add_duration_to_meme_medias_table.php +++ /dev/null @@ -1,28 +0,0 @@ -double('duration')->nullable(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('meme_medias', function (Blueprint $table) { - $table->dropColumn('duration'); - }); - } -}; diff --git a/database/migrations/2025_06_18_043427_add_media_dimensions_to_meme_medias_table.php b/database/migrations/2025_06_18_043427_add_media_dimensions_to_meme_medias_table.php deleted file mode 100644 index 1b38d1a..0000000 --- a/database/migrations/2025_06_18_043427_add_media_dimensions_to_meme_medias_table.php +++ /dev/null @@ -1,30 +0,0 @@ -integer('media_width')->default(720); - $table->integer('media_height')->default(1280); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('meme_medias', function (Blueprint $table) { - $table->dropColumn('media_width'); - $table->dropColumn('media_height'); - }); - } -}; diff --git a/database/migrations/2025_06_18_043437_add_media_dimensions_to_background_medias_table.php b/database/migrations/2025_06_18_043437_add_media_dimensions_to_background_medias_table.php deleted file mode 100644 index b5d6917..0000000 --- a/database/migrations/2025_06_18_043437_add_media_dimensions_to_background_medias_table.php +++ /dev/null @@ -1,30 +0,0 @@ -integer('media_width')->default(720); - $table->integer('media_height')->default(720); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('background_medias', function (Blueprint $table) { - $table->dropColumn('media_width'); - $table->dropColumn('media_height'); - }); - } -}; diff --git a/database/migrations/2025_06_19_002713_create_tag_tables.php b/database/migrations/2025_06_19_002713_create_tag_tables.php new file mode 100644 index 0000000..5925c6c --- /dev/null +++ b/database/migrations/2025_06_19_002713_create_tag_tables.php @@ -0,0 +1,36 @@ +id(); + + $table->json('name'); + $table->json('slug'); + $table->string('type')->nullable(); + $table->integer('order_column')->nullable(); + + $table->timestamps(); + }); + + Schema::create('taggables', function (Blueprint $table) { + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + + $table->morphs('taggable'); + + $table->unique(['tag_id', 'taggable_id', 'taggable_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('taggables'); + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2025_06_19_002727_create_categories_table.php b/database/migrations/2025_06_19_002727_create_categories_table.php new file mode 100644 index 0000000..5a8f5c6 --- /dev/null +++ b/database/migrations/2025_06_19_002727_create_categories_table.php @@ -0,0 +1,44 @@ +id(); + $table->boolean('is_enabled')->default(false); + $table->string('name'); + $table->text('description'); + $table->vector('embedding', 384); + + // Nested set model columns (Kalnoy NestedSet) + $table->nestedSet(); + + // Nullable columns for category/subcategory-specific fields + $table->json('subcategories')->nullable(); + $table->json('meme_angles')->nullable(); + $table->json('sample_captions')->nullable(); + $table->json('keywords')->nullable(); + + // Store original JSON payload + $table->json('payload'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2025_06_07_131622_create_meme_medias_table.php b/database/migrations/2025_06_19_002728_create_meme_medias_table.php similarity index 81% rename from database/migrations/2025_06_07_131622_create_meme_medias_table.php rename to database/migrations/2025_06_19_002728_create_meme_medias_table.php index 671dfe2..53b7cb0 100644 --- a/database/migrations/2025_06_07_131622_create_meme_medias_table.php +++ b/database/migrations/2025_06_19_002728_create_meme_medias_table.php @@ -13,12 +13,13 @@ public function up(): void { Schema::create('meme_medias', function (Blueprint $table) { $table->id(); + $table->boolean('is_enabled')->default(false); $table->string('original_id'); $table->enum('type', ['video', 'image']); $table->enum('sub_type', ['background', 'overlay']); $table->string('name'); $table->text('description'); - $table->string('keywords'); + $table->json('keywords'); $table->uuid('mov_uuid'); $table->uuid('webm_uuid'); $table->uuid('gif_uuid'); @@ -28,6 +29,9 @@ public function up(): void $table->string('gif_url'); $table->string('webp_url'); $table->vector('embedding', 384)->nullable(); + $table->double('duration')->nullable(); + $table->integer('media_width')->default(720); + $table->integer('media_height')->default(1280); $table->timestamps(); $table->softDeletes(); }); diff --git a/database/migrations/2025_06_13_053140_create_background_medias_table.php b/database/migrations/2025_06_19_002729_create_background_medias_table.php similarity index 85% rename from database/migrations/2025_06_13_053140_create_background_medias_table.php rename to database/migrations/2025_06_19_002729_create_background_medias_table.php index 6a93b34..cae84d5 100644 --- a/database/migrations/2025_06_13_053140_create_background_medias_table.php +++ b/database/migrations/2025_06_19_002729_create_background_medias_table.php @@ -13,19 +13,20 @@ public function up(): void { Schema::create('background_medias', function (Blueprint $table) { $table->id(); - $table->string('list_type'); - $table->enum('area', ['interior', 'exterior']); - $table->string('location_name'); $table->text('prompt')->nullable(); $table->enum('status', ['pending_media', 'completed'])->default('pending_media'); $table->uuid('media_uuid')->nullable(); $table->string('media_url')->nullable(); $table->vector('embedding', 384)->nullable(); + $table->integer('media_width'); + $table->integer('media_height'); + $table->enum('aspect_ratio', ['9:16', '16:9', '1:1']); $table->timestamps(); $table->softDeletes(); }); } + /** * Reverse the migrations. */ diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..f5fe6b4 --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,230 @@ +command->error("JSON directory not found: {$jsonPath}"); + return; + } + + // Get all JSON files except the schema file + $jsonFiles = File::glob($jsonPath . '/*.json'); + $jsonFiles = array_filter($jsonFiles, function ($file) { + return !str_contains(basename($file), 'schema'); + }); + + $this->command->info('Starting to seed categories from JSON files...'); + $this->command->info('Found ' . count($jsonFiles) . ' JSON files to process.'); + + foreach ($jsonFiles as $jsonFile) { + $this->processJsonFile($jsonFile); + } + + $this->command->info('Category seeding completed successfully!'); + } + + /** + * Process a single JSON file + */ + private function processJsonFile(string $filePath): void + { + $fileName = basename($filePath); + $this->command->info("Processing: {$fileName}"); + + try { + // Read and decode JSON content + $jsonContent = File::get($filePath); + $data = json_decode($jsonContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->command->error("Invalid JSON in file: {$fileName} - " . json_last_error_msg()); + return; + } + + // Validate JSON structure + if (!isset($data['category'])) { + $this->command->error("Missing 'category' key in file: {$fileName}"); + return; + } + + $categoryData = $data['category']; + + // Validate required fields + if (!isset($categoryData['name']) || !isset($categoryData['description'])) { + $this->command->error("Missing required fields (name/description) in file: {$fileName}"); + return; + } + + // Create main category + $mainCategory = $this->createMainCategory($categoryData, $data); + + if (!$mainCategory) { + $this->command->error("Failed to create main category for file: {$fileName}"); + return; + } + + // Create subcategories + if (isset($categoryData['subcategories']) && is_array($categoryData['subcategories'])) { + foreach ($categoryData['subcategories'] as $index => $subcategoryData) { + if (!$this->createSubcategory($subcategoryData, $mainCategory, $data, $index)) { + $this->command->warn("Failed to create subcategory at index {$index} for file: {$fileName}"); + } + } + } + + $this->command->info("✓ Successfully processed: {$fileName}"); + } catch (\Exception $e) { + $this->command->error("Error processing {$fileName}: " . $e->getMessage()); + Log::error("CategorySeeder error for {$fileName}", [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + } + + /** + * Create a main category + */ + private function createMainCategory(array $categoryData, array $originalData): ?Category + { + try { + // Check if category already exists + $existingCategory = Category::where('name', $categoryData['name']) + ->whereNull('parent_id') + ->first(); + + if ($existingCategory) { + $this->command->warn("Main category '{$categoryData['name']}' already exists. Skipping..."); + return $existingCategory; + } + + // Create the main category + $category = Category::create([ + 'is_enabled' => true, + 'name' => $categoryData['name'], + 'description' => $categoryData['description'], + 'subcategories' => $categoryData['subcategories'] ?? null, + 'keywords' => $categoryData['keywords'] ?? null, + 'meme_angles' => null, // Main categories don't have meme_angles + 'sample_captions' => null, // Main categories don't have sample_captions + 'payload' => $originalData, + 'embedding' => CloudflareAI::getVectorEmbeddingBgeSmall($categoryData['name'] . " " . $categoryData['description']), + ]); + + // Add keywords as tags + if (isset($categoryData['keywords']) && is_array($categoryData['keywords'])) { + $this->attachKeywordsAsTags($category, $categoryData['keywords']); + } + + $this->command->line(" ✓ Created main category: {$category->name}"); + + return $category; + } catch (\Exception $e) { + $this->command->error("Error creating main category: " . $e->getMessage()); + Log::error("Error creating main category", [ + 'category_data' => $categoryData, + 'error' => $e->getMessage() + ]); + return null; + } + } + + /** + * Create a subcategory + */ + private function createSubcategory(array $subcategoryData, Category $parentCategory, array $originalData, int $index): ?Category + { + try { + // Validate required subcategory fields + if (!isset($subcategoryData['name']) || !isset($subcategoryData['description'])) { + $this->command->warn("Subcategory at index {$index} missing required fields (name/description). Skipping..."); + return null; + } + + // Check if subcategory already exists + $existingSubcategory = Category::where('name', $subcategoryData['name']) + ->where('parent_id', $parentCategory->id) + ->first(); + + if ($existingSubcategory) { + $this->command->warn(" Subcategory '{$subcategoryData['name']}' already exists. Skipping..."); + return $existingSubcategory; + } + + // Create subcategory payload + $subcategoryPayload = [ + 'subcategory' => $subcategoryData, + 'parent_category' => [ + 'name' => $parentCategory->name, + 'description' => $parentCategory->description + ] + ]; + + // Create the subcategory using the correct nested set method + $subcategory = Category::create([ + 'is_enabled' => false, + 'name' => $subcategoryData['name'], + 'description' => $subcategoryData['description'], + 'meme_angles' => $subcategoryData['meme_angles'] ?? null, + 'sample_captions' => $subcategoryData['sample_captions'] ?? null, + 'keywords' => $subcategoryData['keywords'] ?? null, + 'subcategories' => null, // Subcategories don't have subcategories + 'payload' => $subcategoryPayload, + 'parent_id' => $parentCategory->id, // Set parent_id directly + 'embedding' => CloudflareAI::getVectorEmbeddingBgeSmall($subcategoryData['name'] . " " . $subcategoryData['description']), + ]); + + // Add keywords as tags + if (isset($subcategoryData['keywords']) && is_array($subcategoryData['keywords'])) { + $this->attachKeywordsAsTags($subcategory, $subcategoryData['keywords']); + } + + $this->command->line(" ✓ Created subcategory: {$subcategory->name}"); + + return $subcategory; + } catch (\Exception $e) { + $this->command->error("Error creating subcategory at index {$index}: " . $e->getMessage()); + Log::error("Error creating subcategory", [ + 'subcategory_data' => $subcategoryData, + 'parent_id' => $parentCategory->id, + 'error' => $e->getMessage() + ]); + return null; + } + } + + /** + * Attach keywords as tags to a category + */ + private function attachKeywordsAsTags(Category $category, array $keywords): void + { + try { + $category->attachTags($keywords, 'category'); + } catch (\Exception $e) { + $this->command->warn("Failed to attach tags to category '{$category->name}': " . $e->getMessage()); + Log::warning("Failed to attach tags", [ + 'category_id' => $category->id, + 'keywords' => $keywords, + 'error' => $e->getMessage() + ]); + } + } +} diff --git a/database/seeders/MemeMediaSeeder.php b/database/seeders/MemeMediaSeeder.php index 3acc138..f5b08ea 100644 --- a/database/seeders/MemeMediaSeeder.php +++ b/database/seeders/MemeMediaSeeder.php @@ -20,10 +20,12 @@ namespace Database\Seeders; use App\Helpers\FirstParty\AI\CloudflareAI; +use App\Helpers\FirstParty\Maintenance\MemeMediaMaintenance; use App\Helpers\FirstParty\MediaEngine\MediaEngine; use App\Models\MediaCollection; use App\Models\MemeMedia; use Illuminate\Database\Seeder; +use Log; class MemeMediaSeeder extends Seeder { @@ -50,7 +52,7 @@ public function run(): void $csv_path = database_path('seeders/data/webm_metadata.csv'); $meme_data = $this->parseCsvFile($csv_path); - $this->command->info('📊 Found '.count($meme_data).' memes to import'); + $this->command->info('📊 Found ' . count($meme_data) . ' memes to import'); // Process records individually for PostgreSQL compatibility $total_processed = 0; @@ -58,7 +60,11 @@ public function run(): void $total_failed = 0; foreach ($meme_data as $index => $meme_record) { - $this->command->info('Processing '.($index + 1).'/'.count($meme_data).': '.$meme_record['filename']); + $this->command->info('Processing ' . ($index + 1) . '/' . count($meme_data) . ': ' . $meme_record['filename']); + + + $meme_record['keywords'] = $this->stringToCleanArray($meme_record['keywords']); + try { // Check for duplicates OUTSIDE of transaction @@ -146,6 +152,18 @@ private function parseCsvFile(string $csv_path): array return $meme_data; } + private function stringToCleanArray($string) + { + // Split by comma, clean each element, and filter empty ones + return array_filter(array_map(function ($item) { + $item = trim($item); // Remove whitespace + $item = preg_replace('/[^\w\s]/', '', $item); // Remove punctuation + return trim(preg_replace('/\s+/', ' ', $item)); // Clean extra spaces + }, explode(',', $string)), function ($value) { + return $value !== ''; + }); + } + /** * Import a single meme with all its formats */ @@ -174,13 +192,13 @@ private function importSingleMeme(array $meme_record): bool 'save_url', // Mode: just save URL reference null, // Auto-generate filename 'r2', // Disk (not used for URL mode) - trim($meme_record['name'])." ({$format})", // Name with format + trim($meme_record['name']) . " ({$format})", // Name with format null, // No specific user $config['mime'] // MIME type ); - $media_uuids[$format.'_uuid'] = $media->uuid; - $media_urls[$format.'_url'] = $url; + $media_uuids[$format . '_uuid'] = $media->uuid; + $media_urls[$format . '_url'] = $url; } catch (\Exception $e) { $this->command->error("Failed to create {$format} media for {$meme_record['filename']}: {$e->getMessage()}"); throw $e; @@ -190,7 +208,7 @@ private function importSingleMeme(array $meme_record): bool // Generate embedding try { $embedding = CloudflareAI::getVectorEmbeddingBgeSmall( - $meme_record['name'].' '.$meme_record['description'].' '.$meme_record['keywords'] + $meme_record['name'] . ' ' . $meme_record['description'] . ' ' . implode(' ', $meme_record['keywords']) ); } catch (\Exception $e) { $this->command->warn("Failed to generate embedding for {$meme_record['filename']}: {$e->getMessage()}"); @@ -209,7 +227,8 @@ private function importSingleMeme(array $meme_record): bool } // Create MemeMedia record - MemeMedia::create([ + $meme_media = MemeMedia::create([ + 'is_enabled' => true, 'original_id' => $meme_record['filename'], 'type' => $meme_record['type'], 'sub_type' => $meme_record['sub_type'], @@ -228,12 +247,16 @@ private function importSingleMeme(array $meme_record): bool 'webm_url' => $media_urls['webm_url'], 'gif_url' => $media_urls['gif_url'], 'webp_url' => $media_urls['webp_url'], - - // Embedding (may be null) - 'embedding' => $embedding, ]); - $this->command->info('✅ Imported: '.trim($meme_record['name'])); + $meme_media->duration = MemeMediaMaintenance::getDurationUsingFfmpeg($meme_media); + $meme_media->embedding = $embedding; + $meme_media->save(); + + // Add keywords as tags + $this->attachKeywordsAsTags($meme_media, $meme_record['keywords']); + + $this->command->info('✅ Imported: ' . trim($meme_record['name'])); return true; } catch (\Exception $e) { @@ -242,12 +265,26 @@ private function importSingleMeme(array $meme_record): bool } } + private function attachKeywordsAsTags(MemeMedia $meme_media, array $keywords): void + { + try { + $meme_media->attachTags($keywords, 'meme'); + } catch (\Exception $e) { + $this->command->warn("Failed to attach tags to meme media '{$meme_media->name}': " . $e->getMessage()); + Log::warning("Failed to attach tags", [ + 'category_id' => $meme_media->id, + 'keywords' => $keywords, + 'error' => $e->getMessage() + ]); + } + } + /** * Generate CDN URL for specific format */ private function generateCdnUrl(string $base_filename, string $extension): string { - return self::CDN_BASE_URL."/{$extension}/{$base_filename}.{$extension}"; + return self::CDN_BASE_URL . "/{$extension}/{$base_filename}.{$extension}"; } /** diff --git a/routes/test.php b/routes/test.php index cc38cc7..c031b7c 100644 --- a/routes/test.php +++ b/routes/test.php @@ -7,3 +7,5 @@ Route::get('/populateDuration', [TestController::class, 'populateDuration']); Route::get('/writeMeme', [TestController::class, 'writeMeme']); + +Route::get('/generateSchnellImage', [TestController::class, 'generateSchnellImage']);