This commit is contained in:
ct
2025-06-19 22:24:47 +08:00
parent 5d3a3c8818
commit 7712101b76
11 changed files with 489 additions and 22 deletions

View File

@@ -9,9 +9,18 @@ class OpenAI
public static function getSingleMemeGenerator($user_prompt) 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([ $response = Http::withHeaders([
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Authorization' => 'Bearer '.env('OPENAI_API_KEY'), 'Authorization' => 'Bearer ' . env('OPENAI_API_KEY'),
]) ])
->post('https://api.openai.com/v1/responses', [ ->post('https://api.openai.com/v1/responses', [
'model' => 'gpt-4.1-nano', 'model' => 'gpt-4.1-nano',
@@ -45,15 +54,19 @@ public static function getSingleMemeGenerator($user_prompt)
'properties' => [ 'properties' => [
'caption' => [ 'caption' => [
'type' => 'string', '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' => [ 'meme_keywords' => [
'type' => 'string', 'type' => 'array',
'description' => 'A short visual reaction that pairs with the caption (e.g., "guy blinking in disbelief")', 'description' => 'Array of 35 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' => [ 'background' => [
'type' => 'string', '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' => [ 'keywords' => [
'type' => 'array', 'type' => 'array',
@@ -65,7 +78,7 @@ public static function getSingleMemeGenerator($user_prompt)
], ],
'required' => [ 'required' => [
'caption', 'caption',
'meme_overlay', 'meme_keywords',
'background', 'background',
'keywords', 'keywords',
], ],
@@ -90,12 +103,24 @@ public static function getSingleMemeGenerator($user_prompt)
return $data; return $data;
} else { } else {
// Handle error // Handle error
throw new \Exception('API request failed: '.$response->body()); throw new \Exception('API request failed: ' . $response->body());
} }
} }
public static function getOpenAIOutput($data) 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;
} }
} }

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Helpers\FirstParty;
class AspectRatio
{
/**
* Get the aspect ratio for given width and height
* Returns common aspect ratios first, then computed ratios
*
* @param int|float $width
* @param int|float $height
* @return string
*/
public static function get($width, $height)
{
// Handle edge cases
if ($width <= 0 || $height <= 0) {
return "Invalid dimensions";
}
// Calculate the actual ratio
$actualRatio = $width / $height;
// Define common aspect ratios with their decimal values and string representations
$commonRatios = [
'1:1' => 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;
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Helpers\FirstParty\Meme;
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\MediaEngine\MediaEngine;
use App\Models\BackgroundMedia;
use App\Models\Category;
use App\Models\Meme;
use App\Models\MemeMedia;
use Pgvector\Laravel\Distance;
use Str;
class MemeGenerator
{
const TYPE_SINGLE_CAPTION_MEME_BACKGROUND = 'single_caption_meme_background';
const STATUS_PENDING = 'pending';
const STATUS_COMPLETED = 'completed';
public static function generateMemeByCategory(Category $category)
{
$meme_output = self::getMemeOutputByCategory($category);
$meme = null;
if ($meme_output->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;
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\FirstParty\MediaEngine\MediaEngine;
use App\Helpers\FirstParty\Meme\MemeGenerator;
use App\Models\BackgroundMedia; use App\Models\BackgroundMedia;
use App\Models\Meme;
use App\Models\MemeMedia; use App\Models\MemeMedia;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,16 +13,15 @@ class FrontMediaController extends Controller
{ {
public function init(Request $request) public function init(Request $request)
{ {
$meme = Meme::with('meme_media', 'background_media')->where('status', MemeGenerator::STATUS_COMPLETED)->take(1)->latest()->first();
$meme = MemeMedia::where('type', 'video')->where('sub_type', 'overlay')->take(1)->inRandomOrder()->first();
$background = BackgroundMedia::where('status', 'completed')->take(1)->inRandomOrder()->first();
return response()->json([ return response()->json([
'success' => [ 'success' => [
'data' => [ 'data' => [
'init' => [ 'init' => [
'meme' => $meme, 'caption' => $meme->caption,
'background' => $background, 'meme' => $meme->meme_media,
'background' => $meme->background_media,
], ],
], ],
], ],

View File

@@ -2,8 +2,15 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\FirstParty\AI\CloudflareAI;
use App\Helpers\FirstParty\AI\OpenAI; use App\Helpers\FirstParty\AI\OpenAI;
use App\Helpers\FirstParty\AI\RunwareAI; 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; use Str;
class TestController extends Controller 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() public function populateDuration()
{ {
\App\Helpers\FirstParty\Maintenance\MemeMediaMaintenance::populateDurations(); \App\Helpers\FirstParty\Maintenance\MemeMediaMaintenance::populateDurations();

View File

@@ -35,17 +35,19 @@ class BackgroundMedia extends Model
protected $casts = [ protected $casts = [
'embedding' => Vector::class, 'embedding' => Vector::class,
'media_width' => 'integer',
'media_height' => 'integer',
]; ];
protected $fillable = [ protected $fillable = [
'list_type',
'area',
'location_name',
'status', 'status',
'media_uuid', 'media_uuid',
'media_url', 'media_url',
'embedding', 'embedding',
'prompt', 'prompt',
'media_width',
'media_height',
'aspect_ratio',
]; ];
protected $hidden = [ protected $hidden = [
@@ -53,9 +55,6 @@ class BackgroundMedia extends Model
'created_at', 'created_at',
'updated_at', 'updated_at',
'deleted_at', 'deleted_at',
'list_type',
'area',
'location_name',
'status', 'status',
'media_uuid', 'media_uuid',
'embedding', 'embedding',
@@ -68,7 +67,7 @@ class BackgroundMedia extends Model
protected function ids(): Attribute protected function ids(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn ($value, $attributes) => hashids_encode($attributes['id']), get: fn($value, $attributes) => hashids_encode($attributes['id']),
); );
} }
} }

78
app/Models/Meme.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Tags\HasTags;
/**
* Class Meme
*
* @property int $id
* @property string $type
* @property int|null $category_id
* @property int|null $user_id
* @property int|null $meme_id
* @property int|null $background_id
* @property string $status
* @property string $prompt
* @property string|null $caption
* @property string|null $meme_keywords
* @property string|null $background
* @property string $keywords
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string|null $deleted_at
*
* @package App\Models
*/
class Meme extends Model
{
use SoftDeletes, HasTags;
protected $table = 'memes';
protected $casts = [
'is_system' => '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');
}
}

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// {#488 ▼ // app/Http/Controllers/TestController.php:24
// +"caption": "thesis writing procrastination blues"
// +"meme_keywords": array:3 [▼
// 0 => "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');
}
};

View File

@@ -268,7 +268,7 @@ private function importSingleMeme(array $meme_record): bool
private function attachKeywordsAsTags(MemeMedia $meme_media, array $keywords): void private function attachKeywordsAsTags(MemeMedia $meme_media, array $keywords): void
{ {
try { try {
$meme_media->attachTags($keywords, 'meme'); $meme_media->attachTags($keywords, 'meme_media');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->command->warn("Failed to attach tags to meme media '{$meme_media->name}': " . $e->getMessage()); $this->command->warn("Failed to attach tags to meme media '{$meme_media->name}': " . $e->getMessage());
Log::warning("Failed to attach tags", [ Log::warning("Failed to attach tags", [

View File

@@ -47,6 +47,7 @@ const useMediaStore = create(
if (response?.data?.success?.data?.init) { if (response?.data?.success?.data?.init) {
set({ set({
currentCaption: response.data.success.data.init.caption,
selectedMeme: response.data.success.data.init.meme, selectedMeme: response.data.success.data.init.meme,
selectedBackground: response.data.success.data.init.background, selectedBackground: response.data.success.data.init.background,
}); });

View File

@@ -9,3 +9,7 @@
Route::get('/writeMeme', [TestController::class, 'writeMeme']); Route::get('/writeMeme', [TestController::class, 'writeMeme']);
Route::get('/generateSchnellImage', [TestController::class, 'generateSchnellImage']); Route::get('/generateSchnellImage', [TestController::class, 'generateSchnellImage']);
Route::get('/generateMeme', [TestController::class, 'generateMeme']);
Route::get('/aspectRatio', [TestController::class, 'aspectRatio']);