From 7712101b76379dca2b57ed5702c3495efb594098 Mon Sep 17 00:00:00 2001 From: ct Date: Thu, 19 Jun 2025 22:24:47 +0800 Subject: [PATCH] Update --- app/Helpers/FirstParty/AI/OpenAI.php | 43 +++- app/Helpers/FirstParty/AspectRatio.php | 93 +++++++++ app/Helpers/FirstParty/Meme/MemeGenerator.php | 183 ++++++++++++++++++ app/Http/Controllers/FrontMediaController.php | 12 +- app/Http/Controllers/TestController.php | 23 +++ app/Models/BackgroundMedia.php | 13 +- app/Models/Meme.php | 78 ++++++++ .../2025_06_19_051802_create_memes_table.php | 59 ++++++ database/seeders/MemeMediaSeeder.php | 2 +- resources/js/stores/MediaStore.js | 1 + routes/test.php | 4 + 11 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 app/Helpers/FirstParty/AspectRatio.php create mode 100644 app/Helpers/FirstParty/Meme/MemeGenerator.php create mode 100644 app/Models/Meme.php create mode 100644 database/migrations/2025_06_19_051802_create_memes_table.php diff --git a/app/Helpers/FirstParty/AI/OpenAI.php b/app/Helpers/FirstParty/AI/OpenAI.php index 2ec6429..aa8e269 100644 --- a/app/Helpers/FirstParty/AI/OpenAI.php +++ b/app/Helpers/FirstParty/AI/OpenAI.php @@ -9,9 +9,18 @@ class OpenAI public static function getSingleMemeGenerator($user_prompt) { + $caption_descriptions = [ + 'A humorous, funny one-liner that starts with "When you", describes a specific visual or situational moment, avoids vagueness, and ends with no punctuation', + 'A POV meme that starts with "POV: ", clearly describes a specific scenario or feeling with high context, and ends with no punctuation', + 'A humorous, funny one-liner that starts with "I", grounded in a relatable, situational experience with visual context, and ends with no punctuation', + 'A humorous, funny one-liner that starts with "You", focused on a clear, specific reaction or moment, and ends with no punctuation', + 'A humorous, funny one-liner with no punctuation that describes a vivid, realistic scenario or reaction in a clearly defined context', + ]; + + $response = Http::withHeaders([ 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer '.env('OPENAI_API_KEY'), + 'Authorization' => 'Bearer ' . env('OPENAI_API_KEY'), ]) ->post('https://api.openai.com/v1/responses', [ 'model' => 'gpt-4.1-nano', @@ -45,15 +54,19 @@ public static function getSingleMemeGenerator($user_prompt) 'properties' => [ 'caption' => [ 'type' => 'string', - 'description' => 'A humorous, one-liner or POV meme (under 15 words, no punctuation)', + 'description' => $caption_descriptions[rand(0, count($caption_descriptions) - 1)], ], - 'meme_overlay' => [ - 'type' => 'string', - 'description' => 'A short visual reaction that pairs with the caption (e.g., "guy blinking in disbelief")', + 'meme_keywords' => [ + 'type' => 'array', + 'description' => 'Array of 3–5 tags that describe only internal emotional states (e.g., "anxiety", "burnout", "imposter syndrome", "joy", "excitement", "confusion", "awkwardness"). Avoid external concepts, situations, or behaviors like "familylife" or "comparison". Only include pure feelings.', + 'items' => [ + 'type' => 'string', + ], ], 'background' => [ 'type' => 'string', - 'description' => 'The setting or location of the meme (e.g., "Zoom call grid", "office cubicle")', + 'description' => "Use this exact structure: 'Location: [setting only, e.g., empty office with cluttered desk and monitor]'. Do not mention people, animals, actions, or any living beings. No verbs. No brand names. Only interior spaces or physical locations. Examples: 'Location: quiet office cubicle with headset and papers'.", + ], 'keywords' => [ 'type' => 'array', @@ -65,7 +78,7 @@ public static function getSingleMemeGenerator($user_prompt) ], 'required' => [ 'caption', - 'meme_overlay', + 'meme_keywords', 'background', 'keywords', ], @@ -90,12 +103,24 @@ public static function getSingleMemeGenerator($user_prompt) return $data; } else { // Handle error - throw new \Exception('API request failed: '.$response->body()); + throw new \Exception('API request failed: ' . $response->body()); } } public static function getOpenAIOutput($data) { - return data_get($data, 'output.0.content.0.text', null); + //dump($data); + + $output = null; + + $response_type = data_get($data, 'output.0.content.0.type', null); + + if ($response_type === 'output_text') { + $output = data_get($data, 'output.0.content.0.text', null); + } else if ($response_type === 'refusal') { + $output = data_get($data, 'output.0.content.0.refusal', null); + } + + return $output; } } diff --git a/app/Helpers/FirstParty/AspectRatio.php b/app/Helpers/FirstParty/AspectRatio.php new file mode 100644 index 0000000..15bb71e --- /dev/null +++ b/app/Helpers/FirstParty/AspectRatio.php @@ -0,0 +1,93 @@ + 1.0, // 1/1 = 1.0 + '4:3' => 4 / 3, // 1.333... + '3:4' => 3 / 4, // 0.75 + '16:9' => 16 / 9, // 1.777... + '9:16' => 9 / 16, // 0.5625 + '20:9' => 20 / 9, // 2.222... + '9:20' => 9 / 20, // 0.45 + '5:4' => 5 / 4, // 1.25 + '4:5' => 4 / 5, // 0.8 + ]; + + // Tolerance for floating point comparison (about 1% difference) + $tolerance = 0.01; + + // Check against common ratios first + foreach ($commonRatios as $ratioString => $ratioValue) { + if (abs($actualRatio - $ratioValue) < $tolerance) { + return $ratioString; + } + } + + // If no common ratio matches, compute the simplified ratio + return self::computeSimplifiedRatio($width, $height); + } + + /** + * Compute simplified aspect ratio using GCD + * + * @param int|float $width + * @param int|float $height + * @return string + */ + private static function computeSimplifiedRatio($width, $height) + { + // Convert to integers for GCD calculation + $intWidth = (int) $width; + $intHeight = (int) $height; + + // Find the greatest common divisor + $gcd = self::gcd($intWidth, $intHeight); + + // Simplify the ratio + $simplifiedWidth = $intWidth / $gcd; + $simplifiedHeight = $intHeight / $gcd; + + return $simplifiedWidth . ':' . $simplifiedHeight; + } + + /** + * Calculate Greatest Common Divisor using Euclidean algorithm + * + * @param int $a + * @param int $b + * @return int + */ + private static function gcd($a, $b) + { + // Euclidean algorithm for finding GCD + while ($b != 0) { + $temp = $b; + $b = $a % $b; + $a = $temp; + } + return $a; + } +} diff --git a/app/Helpers/FirstParty/Meme/MemeGenerator.php b/app/Helpers/FirstParty/Meme/MemeGenerator.php new file mode 100644 index 0000000..273693a --- /dev/null +++ b/app/Helpers/FirstParty/Meme/MemeGenerator.php @@ -0,0 +1,183 @@ +success) { + $meme = Meme::create([ + 'type' => self::TYPE_SINGLE_CAPTION_MEME_BACKGROUND, + 'prompt' => $meme_output->prompt, + 'category_id' => $category->id, + 'caption' => $meme_output->caption, + 'meme_keywords' => $meme_output->keywords, + 'background' => $meme_output->background, + 'keywords' => $meme_output->keywords, + 'is_system' => true, + 'status' => self::STATUS_PENDING + ]); + + $meme->attachTags($meme_output->keywords, 'meme'); + } + + if (!is_null($meme) && $meme->status == self::STATUS_PENDING) { + // populate meme_id + $meme->meme_id = self::getMemeMediaByKeywords($meme_output->keywords)->id; + $meme->background_id = self::generateBackgroundMediaWithRunware($meme_output->background)->id; + + if (!is_null($meme->meme_id) && !is_null($meme->background_id)) { + $meme->status = self::STATUS_COMPLETED; + } + + $meme->save(); + } + + return $meme; + } + + public static function getMemeOutputByCategory(Category $category) + { + $retries = 3; + $attempt = 0; + + $random_keyword = Str::lower($category->name); + + if (!is_null($category->parent_id)) { + $random_keyword = $category->parent->name . " - " . $random_keyword; + } + + if (!is_null($category->meme_angles)) { + $random_keyword .= " - " . collect($category->meme_angles)->random(); + } else if (!is_null($category->keywords)) { + $random_keyword .= " - " . collect($category->keywords)->random(); + } + + $prompt = "Write me 1 meme about {$random_keyword}"; + + // RETRY MECHANISM START + do { + $attempt++; + + $meme_response = OpenAI::getSingleMemeGenerator($prompt); + $meme_output = json_decode(OpenAI::getOpenAIOutput($meme_response)); + + $output_is_valid = false; + + if (!is_null($meme_output)) { + if ( + isset($meme_output->caption) && + isset($meme_output->meme_keywords) && + isset($meme_output->background) && + isset($meme_output->keywords) + ) { + $output_is_valid = true; + } + } + + // If output is valid or we've exhausted all retries, break the loop + if ($output_is_valid || $attempt >= $retries) { + break; + } + + // Optional: Add a small delay between retries to avoid rate limiting + // sleep(1); + + } while ($attempt < $retries); + // RETRY MECHANISM END + + if ($output_is_valid) { + $meme_output->success = true; + $meme_output->prompt = $prompt; + $meme_output->category = $category; + $meme_output->attempts = $attempt; // Optional: track how many attempts it took + } else { + $meme_output = (object) [ + 'success' => false, + 'attempts' => $attempt, // Optional: track how many attempts were made + 'error' => 'Failed to generate valid meme after ' . $retries . ' attempts' + ]; + } + + return $meme_output; + } + + public static function generateBackgroundMediaWithRunware($prompt) + { + $media_width = 1024; + $media_height = 1024; + $aspect_ratio = AspectRatio::get($media_width, $media_height); + + + $runware_output_url = RunwareAI::generateSchnellImage(Str::uuid(), $prompt, $media_width, $media_height); + + $media = MediaEngine::addMedia( + 'system-i', + 'image', + 'system_uploaded', + 'replicate', + null, + $runware_output_url, + 'download' + ); + + $background_media = BackgroundMedia::create([ + 'media_uuid' => $media->uuid, + 'media_url' => MediaEngine::getMediaCloudUrl($media), + 'prompt' => $prompt, + 'status' => 'completed', + 'aspect_ratio' => $aspect_ratio, + 'media_width' => $media_width, + 'media_height' => $media_height, + + ]); + + return $background_media; + } + + public static function getMemeMediaByKeywords(array $keywords) + { + $meme_media = null; + + $meme_medias = MemeMedia::withAnyTags($keywords)->take(10)->get(); + + if ($meme_medias->count() > 0) { + $meme_media = $meme_medias->random(); + } + + if (is_null($meme_media)) { + $meme_embedding = CloudflareAI::getVectorEmbeddingBgeSmall(implode(' ', $keywords)); + + $meme_medias = MemeMedia::query()->nearestNeighbors('embedding', $meme_embedding, Distance::L2)->take(10)->get(); + + if ($meme_medias->count() > 0) { + $meme_media = $meme_medias->random(); + } + } + + return $meme_media; + } +} diff --git a/app/Http/Controllers/FrontMediaController.php b/app/Http/Controllers/FrontMediaController.php index ac8c336..0c3746d 100644 --- a/app/Http/Controllers/FrontMediaController.php +++ b/app/Http/Controllers/FrontMediaController.php @@ -2,7 +2,10 @@ namespace App\Http\Controllers; +use App\Helpers\FirstParty\MediaEngine\MediaEngine; +use App\Helpers\FirstParty\Meme\MemeGenerator; use App\Models\BackgroundMedia; +use App\Models\Meme; use App\Models\MemeMedia; use Illuminate\Http\Request; @@ -10,16 +13,15 @@ class FrontMediaController extends Controller { public function init(Request $request) { - - $meme = MemeMedia::where('type', 'video')->where('sub_type', 'overlay')->take(1)->inRandomOrder()->first(); - $background = BackgroundMedia::where('status', 'completed')->take(1)->inRandomOrder()->first(); + $meme = Meme::with('meme_media', 'background_media')->where('status', MemeGenerator::STATUS_COMPLETED)->take(1)->latest()->first(); return response()->json([ 'success' => [ 'data' => [ 'init' => [ - 'meme' => $meme, - 'background' => $background, + 'caption' => $meme->caption, + 'meme' => $meme->meme_media, + 'background' => $meme->background_media, ], ], ], diff --git a/app/Http/Controllers/TestController.php b/app/Http/Controllers/TestController.php index 931c8db..abc1d54 100644 --- a/app/Http/Controllers/TestController.php +++ b/app/Http/Controllers/TestController.php @@ -2,8 +2,15 @@ namespace App\Http\Controllers; +use App\Helpers\FirstParty\AI\CloudflareAI; use App\Helpers\FirstParty\AI\OpenAI; use App\Helpers\FirstParty\AI\RunwareAI; +use App\Helpers\FirstParty\AspectRatio; +use App\Helpers\FirstParty\Meme\MemeGenerator; +use App\Models\Category; +use App\Models\Meme; +use App\Models\MemeMedia; +use Pgvector\Laravel\Distance; use Str; class TestController extends Controller @@ -13,6 +20,22 @@ public function index() // } + public function aspectRatio() + { + $aspect_ratio = AspectRatio::get(1024, 1024); + + dd($aspect_ratio); + } + + public function generateMeme() + { + $category = Category::inRandomOrder()->first(); + + $meme = MemeGenerator::generateMemeByCategory($category); + + dd($meme); + } + public function populateDuration() { \App\Helpers\FirstParty\Maintenance\MemeMediaMaintenance::populateDurations(); diff --git a/app/Models/BackgroundMedia.php b/app/Models/BackgroundMedia.php index 45a6c34..cc6b53d 100644 --- a/app/Models/BackgroundMedia.php +++ b/app/Models/BackgroundMedia.php @@ -35,17 +35,19 @@ class BackgroundMedia extends Model protected $casts = [ 'embedding' => Vector::class, + 'media_width' => 'integer', + 'media_height' => 'integer', ]; protected $fillable = [ - 'list_type', - 'area', - 'location_name', 'status', 'media_uuid', 'media_url', 'embedding', 'prompt', + 'media_width', + 'media_height', + 'aspect_ratio', ]; protected $hidden = [ @@ -53,9 +55,6 @@ class BackgroundMedia extends Model 'created_at', 'updated_at', 'deleted_at', - 'list_type', - 'area', - 'location_name', 'status', 'media_uuid', 'embedding', @@ -68,7 +67,7 @@ class BackgroundMedia 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/app/Models/Meme.php b/app/Models/Meme.php new file mode 100644 index 0000000..8e9da91 --- /dev/null +++ b/app/Models/Meme.php @@ -0,0 +1,78 @@ + 'boolean', + + 'category_id' => 'int', + 'user_id' => 'int', + 'meme_id' => 'int', + 'background_id' => 'int', + + 'meme_keywords' => 'array', + 'keywords' => 'array', + ]; + + protected $fillable = [ + 'type', + 'is_system', + 'category_id', + 'user_id', + 'meme_id', + 'background_id', + 'status', + 'prompt', + 'caption', + 'meme_keywords', + 'background', + 'keywords' + ]; + + public function meme_media() + { + return $this->hasOne(MemeMedia::class, 'id', 'meme_id'); + } + + public function background_media() + { + return $this->hasOne(BackgroundMedia::class, 'id', 'background_id'); + } +} diff --git a/database/migrations/2025_06_19_051802_create_memes_table.php b/database/migrations/2025_06_19_051802_create_memes_table.php new file mode 100644 index 0000000..59b55fb --- /dev/null +++ b/database/migrations/2025_06_19_051802_create_memes_table.php @@ -0,0 +1,59 @@ + "anxiety" + // 1 => "overwhelm" + // 2 => "imposter syndrome" + // ] + // +"background": "cluttered desk with piles of papers and a laptop" + // +"keywords": array:3 [▼ + // 0 => "thesis" + // 1 => "anxiety" + // 2 => "breaks" + // ] + // } + Schema::create('memes', function (Blueprint $table) { + $table->id(); + + $table->enum('type', ['single_caption_meme_background'])->default('single_caption_meme_background'); + $table->boolean('is_system'); + + $table->foreignId('category_id')->nullable(); + $table->foreignId('user_id')->nullable(); + $table->foreignId('meme_id')->nullable(); + $table->foreignId('background_id')->nullable(); + + $table->enum('status', ['pending', 'completed'])->default('pending'); + $table->text('prompt'); + $table->string('caption')->nullable(); + $table->json('meme_keywords')->nullable(); + $table->string('background')->nullable(); + $table->json('keywords'); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('memes'); + } +}; diff --git a/database/seeders/MemeMediaSeeder.php b/database/seeders/MemeMediaSeeder.php index f5b08ea..3945414 100644 --- a/database/seeders/MemeMediaSeeder.php +++ b/database/seeders/MemeMediaSeeder.php @@ -268,7 +268,7 @@ private function importSingleMeme(array $meme_record): bool private function attachKeywordsAsTags(MemeMedia $meme_media, array $keywords): void { try { - $meme_media->attachTags($keywords, 'meme'); + $meme_media->attachTags($keywords, 'meme_media'); } catch (\Exception $e) { $this->command->warn("Failed to attach tags to meme media '{$meme_media->name}': " . $e->getMessage()); Log::warning("Failed to attach tags", [ diff --git a/resources/js/stores/MediaStore.js b/resources/js/stores/MediaStore.js index a5dc2b4..473e194 100644 --- a/resources/js/stores/MediaStore.js +++ b/resources/js/stores/MediaStore.js @@ -47,6 +47,7 @@ const useMediaStore = create( if (response?.data?.success?.data?.init) { set({ + currentCaption: response.data.success.data.init.caption, selectedMeme: response.data.success.data.init.meme, selectedBackground: response.data.success.data.init.background, }); diff --git a/routes/test.php b/routes/test.php index c031b7c..547f898 100644 --- a/routes/test.php +++ b/routes/test.php @@ -9,3 +9,7 @@ Route::get('/writeMeme', [TestController::class, 'writeMeme']); Route::get('/generateSchnellImage', [TestController::class, 'generateSchnellImage']); + +Route::get('/generateMeme', [TestController::class, 'generateMeme']); + +Route::get('/aspectRatio', [TestController::class, 'aspectRatio']);