This commit is contained in:
ct
2025-07-04 03:10:37 +08:00
parent 239b74fd5a
commit 21b56b6cf0
6 changed files with 411 additions and 141 deletions

View File

@@ -12,136 +12,190 @@ post {
body:json { body:json {
{ {
"id": "evt_1RgRPrEEXQJo9EEOWggvNEHe", "id": "evt_1RghW8EEXQJo9EEOI2W1LyN4",
"object": "event", "object": "event",
"api_version": "2025-05-28.basil", "api_version": "2025-05-28.basil",
"created": 1751465619, "created": 1751527512,
"data": { "data": {
"object": { "object": {
"id": "cs_test_a1RMSyzQu5Mk2cZYoAYLOCV9hfoc5z9RuYdE4jLY2htoizGr1AJZeAcnDh", "id": "sub_1RgU1iEEXQJo9EEOBmsO7ZIo",
"object": "checkout.session", "object": "subscription",
"adaptive_pricing": { "application": null,
"enabled": true "application_fee_percent": null,
},
"after_expiration": null,
"allow_promotion_codes": null,
"amount_subtotal": 400,
"amount_total": 400,
"automatic_tax": { "automatic_tax": {
"disabled_reason": null,
"enabled": false, "enabled": false,
"liability": null, "liability": null
"provider": null,
"status": null
}, },
"billing_address_collection": null, "billing_cycle_anchor": 1751475654,
"cancel_url": "https://memeaigen.test/subscribe/cancelled?session_id={CHECKOUT_SESSION_ID}", "billing_cycle_anchor_config": null,
"client_reference_id": null, "billing_mode": {
"client_secret": null, "type": "classic"
"collected_information": {
"shipping_details": null
}, },
"consent": null, "billing_thresholds": null,
"consent_collection": null, "cancel_at": null,
"created": 1751465613, "cancel_at_period_end": false,
"canceled_at": 1751527511,
"cancellation_details": {
"comment": null,
"feedback": null,
"reason": "cancellation_requested"
},
"collection_method": "charge_automatically",
"created": 1751475654,
"currency": "usd", "currency": "usd",
"currency_conversion": null,
"custom_fields": [],
"custom_text": {
"after_submit": null,
"shipping_address": null,
"submit": null,
"terms_of_service_acceptance": null
},
"customer": "cus_SbGYl34MpG4nv5", "customer": "cus_SbGYl34MpG4nv5",
"customer_creation": null, "days_until_due": null,
"customer_details": { "default_payment_method": "pm_1RgU1hEEXQJo9EEOBnHcyDTz",
"address": { "default_source": null,
"city": null, "default_tax_rates": [],
"country": "MY", "description": null,
"line1": null,
"line2": null,
"postal_code": null,
"state": null
},
"email": "memeaigen.com@gmail.com",
"name": "TEST NAME",
"phone": null,
"tax_exempt": "none",
"tax_ids": []
},
"customer_email": null,
"discounts": [], "discounts": [],
"expires_at": 1751552013, "ended_at": 1751527511,
"invoice": null, "invoice_settings": {
"invoice_creation": { "account_tax_ids": null,
"enabled": false, "issuer": {
"invoice_data": { "type": "self"
"account_tax_ids": null,
"custom_fields": null,
"description": null,
"footer": null,
"issuer": null,
"metadata": {},
"rendering_options": null
} }
}, },
"livemode": false, "items": {
"locale": null, "object": "list",
"metadata": {}, "data": [
"mode": "payment", {
"payment_intent": "pi_3RgRPqEEXQJo9EEO1a43uB5g", "id": "si_SbhQOOEJ1xUQ7Y",
"payment_link": null, "object": "subscription_item",
"payment_method_collection": "if_required", "billing_thresholds": null,
"payment_method_configuration_details": { "created": 1751475654,
"id": "pmc_1RfN0QEEXQJo9EEOzYHrN3LV", "current_period_end": 1754154054,
"parent": null "current_period_start": 1751475654,
}, "discounts": [],
"payment_method_options": { "metadata": {},
"card": { "plan": {
"request_three_d_secure": "automatic" "id": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
} "object": "plan",
}, "active": true,
"payment_method_types": [ "amount": 400,
"card", "amount_decimal": "400",
"link" "billing_scheme": "per_unit",
], "created": 1751210467,
"payment_status": "paid", "currency": "usd",
"permissions": null, "interval": "month",
"phone_number_collection": { "interval_count": 1,
"enabled": false "livemode": false,
}, "metadata": {},
"recovered_from": null, "meter": null,
"saved_payment_method_options": { "nickname": null,
"allow_redisplay_filters": [ "product": "prod_SaY8TGjiPi5hWu",
"always" "tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"price": {
"id": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1751210467,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"product": "prod_SaY8TGjiPi5hWu",
"recurring": {
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 400,
"unit_amount_decimal": "400"
},
"quantity": 1,
"subscription": "sub_1RgU1iEEXQJo9EEOBmsO7ZIo",
"tax_rates": []
}
], ],
"payment_method_remove": "disabled", "has_more": false,
"payment_method_save": null "total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1RgU1iEEXQJo9EEOBmsO7ZIo"
}, },
"setup_intent": null, "latest_invoice": "in_1RgU1iEEXQJo9EEObpcu0isX",
"shipping_address_collection": null, "livemode": false,
"shipping_cost": null, "metadata": {
"shipping_options": [], "is_on_session_checkout": "true"
"status": "complete",
"submit_type": null,
"subscription": null,
"success_url": "https://memeaigen.test/subscribe/success?session_id={CHECKOUT_SESSION_ID}",
"total_details": {
"amount_discount": 0,
"amount_shipping": 0,
"amount_tax": 0
}, },
"ui_mode": "hosted", "next_pending_invoice_item_invoice": null,
"url": null, "on_behalf_of": null,
"wallet_options": null "pause_collection": null,
"payment_settings": {
"payment_method_options": {
"acss_debit": null,
"bancontact": null,
"card": {
"network": null,
"request_three_d_secure": "automatic"
},
"customer_balance": null,
"konbini": null,
"sepa_debit": null,
"us_bank_account": null
},
"payment_method_types": null,
"save_default_payment_method": "off"
},
"pending_invoice_item_interval": null,
"pending_setup_intent": null,
"pending_update": null,
"plan": {
"id": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
"object": "plan",
"active": true,
"amount": 400,
"amount_decimal": "400",
"billing_scheme": "per_unit",
"created": 1751210467,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {},
"meter": null,
"nickname": null,
"product": "prod_SaY8TGjiPi5hWu",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 1,
"schedule": null,
"start_date": 1751475654,
"status": "canceled",
"test_clock": null,
"transfer_data": null,
"trial_end": null,
"trial_settings": {
"end_behavior": {
"missing_payment_method": "create_invoice"
}
},
"trial_start": null
} }
}, },
"livemode": false, "livemode": false,
"pending_webhooks": 2, "pending_webhooks": 2,
"request": { "request": {
"id": null, "id": "req_cCXhmqNMHSgIwr",
"idempotency_key": null "idempotency_key": null
}, },
"type": "checkout.session.completed" "type": "customer.subscription.deleted"
} }
} }

View File

@@ -108,24 +108,58 @@ public static function generateMemeByCategory(Category $category)
return $meme; return $meme;
} }
public static function generateMemeOutputByCategory(Category $category) public static function generateMemeByKeyword($keyword)
{ {
$meme_output = self::generateMemeOutputByKeyword($keyword);
$meme = null;
if ($meme_output->success) {
$meme = Meme::create([
'type' => self::TYPE_SINGLE_CAPTION_MEME_BACKGROUND,
'prompt' => $meme_output->prompt,
'category_id' => null,
'caption' => $meme_output->caption,
'meme_keywords' => $meme_output->keywords,
'background' => $meme_output->background,
'keywords' => $meme_output->keywords,
'is_system' => true,
'status' => self::STATUS_PENDING,
'primary_keyword_type' => $meme_output->primary_keyword_type,
'action_keywords' => $meme_output->action_keywords,
'emotion_keywords' => $meme_output->emotion_keywords,
'misc_keywords' => $meme_output->misc_keywords,
]);
$meme->attachTags($meme_output->keywords, 'meme');
}
if (! is_null($meme) && $meme->status == self::STATUS_PENDING) {
// populate meme_media_id
$meme->meme_media_id = self::getSuitableMemeMedia($meme)->id;
$meme->background_media_id = self::generateBackgroundMediaWithRunware($meme_output->background)->id;
if (
// !is_null($meme->meme_media_id) &&
! is_null($meme->background_media_id)
) {
$meme->status = self::STATUS_COMPLETED;
}
$meme->save();
}
return $meme;
}
public static function generateMemeOutputByKeyword($keyword, $category = null)
{
$retries = 3; $retries = 3;
$attempt = 0; $attempt = 0;
$random_keyword = Str::lower($category->name); $prompt = "Write me 1 meme about {$keyword}";
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();
} elseif (! is_null($category->keywords)) {
$random_keyword .= ' - '.collect($category->keywords)->random();
}
$prompt = "Write me 1 meme about {$random_keyword}";
// RETRY MECHANISM START // RETRY MECHANISM START
do { do {
@@ -169,13 +203,31 @@ public static function generateMemeOutputByCategory(Category $category)
$meme_output = (object) [ $meme_output = (object) [
'success' => false, 'success' => false,
'attempts' => $attempt, // Optional: track how many attempts were made 'attempts' => $attempt, // Optional: track how many attempts were made
'error' => 'Failed to generate valid meme after '.$retries.' attempts', 'error' => 'Failed to generate valid meme after ' . $retries . ' attempts',
]; ];
} }
return $meme_output; return $meme_output;
} }
public static function generateMemeOutputByCategory(Category $category)
{
$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();
} elseif (! is_null($category->keywords)) {
$random_keyword .= ' - ' . collect($category->keywords)->random();
}
return self::generateMemeOutputByKeyword($random_keyword, $category);
}
public static function generateBackgroundMediaWithRunware($prompt) public static function generateBackgroundMediaWithRunware($prompt)
{ {
$media_width = 1024; $media_width = 1024;

View File

@@ -2,12 +2,74 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Helpers\FirstParty\Credits\CreditsService;
use App\Helpers\FirstParty\Meme\MemeGenerator;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Category;
use Auth;
class UserAIController extends Controller class UserAIController extends Controller
{ {
public function generateMeme(Request $request)
{
$user = Auth::user();
if (!CreditsService::canSpend($user->id, 2)) {
return response()->json([
'error' => [
'message' => 'You do not have enough credits to generate a meme. Please purchase credits from the Store.',
],
]);
}
CreditsService::spend($user->id, 2);
$meme = MemeGenerator::generateMemeByKeyword($request->prompt);
$meme_media = MemeGenerator::getSuitableMemeMedia($meme, 2);
return response()->json([
'success' => [
'data' => [
'generate' => [
'info' => $meme,
'caption' => $meme->caption,
'meme' => $meme_media,
'background' => $meme->background_media,
],
],
],
]);
}
public function aiHints() public function aiHints()
{ {
// TODO: Take 5 Category where meme_angles is not null, and return a random one from the array $categories = Category::whereNotNull('keywords')
->inRandomOrder()
->take(8)
->get();
if ($categories->isEmpty()) {
return response()->json([
'success' => [
'data' => [
'keywords' => []
]
]
]);
}
$keywords = $categories->map(function ($category) {
return collect($category->keywords)->random();
})->toArray();
return response()->json([
'success' => [
'data' => [
'keywords' => $keywords
]
]
]);
} }
} }

View File

@@ -3,19 +3,28 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useMitt } from '@/plugins/MittContext'; import { useMitt } from '@/plugins/MittContext';
import CoinIcon from '@/reusables/coin-icon'; import CoinIcon from '@/reusables/coin-icon';
import useMediaStore from '@/stores/MediaStore';
import useUserStore from '@/stores/UserStore';
import { usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const EditorAISheet = () => { const EditorAISheet = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const emitter = useMitt(); const emitter = useMitt();
const { generateMeme, isGeneratingMeme, keywords, isLoadingAIHints, fetchAIHints } = useMediaStore();
const { user } = useUserStore();
const { auth } = usePage().props;
useEffect(() => { useEffect(() => {
const openSheetListener = () => { const openSheetListener = () => {
setIsOpen(true); setIsOpen(true);
fetchAIHints();
}; };
emitter.on('open-ai-editor-sheet', openSheetListener); emitter.on('open-ai-editor-sheet', openSheetListener);
@@ -23,7 +32,7 @@ const EditorAISheet = () => {
return () => { return () => {
emitter.off('open-ai-editor-sheet', openSheetListener); emitter.off('open-ai-editor-sheet', openSheetListener);
}; };
}, [emitter]); }, [emitter, fetchAIHints]);
const handleOpenChange = (open) => { const handleOpenChange = (open) => {
setIsOpen(open); setIsOpen(open);
@@ -31,39 +40,77 @@ const EditorAISheet = () => {
const handleSend = () => { const handleSend = () => {
if (prompt.trim()) { if (prompt.trim()) {
console.log('Sending prompt:', prompt); generateMeme(prompt);
setPrompt('');
} }
}; };
return ( return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}> <Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent side="bottom" className="gap-0! rounded-t-4xl pb-1"> <SheetContent
<SheetHeader className="mb-2"> side="bottom"
<SheetTitle className="text-center text-2xl font-semibold">What can I help with?</SheetTitle> className={cn('gap-0! rounded-t-4xl pb-1', isGeneratingMeme && '[&>button]:hidden')}
onInteractOutside={(e) => isGeneratingMeme && e.preventDefault()}
onEscapeKeyDown={(e) => isGeneratingMeme && e.preventDefault()}
>
<SheetHeader className="mb-2 px-5">
<SheetTitle className="text-center text-xl font-semibold text-balance">
{isGeneratingMeme ? 'Creating...' : 'What meme would you like to create?'}
</SheetTitle>
<SheetDescription className="hidden"></SheetDescription> <SheetDescription className="hidden"></SheetDescription>
</SheetHeader> </SheetHeader>
<div className="mx-auto w-full max-w-[600px] space-y-4 px-4 pb-4"> <div className="mx-auto w-full max-w-[600px] space-y-4 px-4 pb-4">
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
disabled={isGeneratingMeme}
placeholder="Make a meme for e.g. work life stress" placeholder="Make a meme for e.g. work life stress"
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
className="bg-muted/30 max-h-20 min-h-12 resize-none rounded-3xl border-0 p-4 text-base" className="bg-muted/30 max-h-20 min-h-12 resize-none rounded-3xl border-0 p-4 text-base"
/> />
<Button
onClick={handleSend} {/* AI Keywords */}
className={cn('w-full rounded-full', !prompt.trim() && 'invisible')} {isLoadingAIHints && <div className="text-muted-foreground text-center text-sm">Loading AI hints...</div>}
size="lg"
variant="outline" {keywords.length > 0 && !isLoadingAIHints && (
disabled={!prompt.trim()} <div className="flex flex-wrap justify-center gap-2">
> {keywords.map((keyword, index) => (
Generate Meme <Button
<div className="flex items-center gap-1"> disabled={isGeneratingMeme}
<CoinIcon></CoinIcon> 1 key={index}
variant="secondary"
size="sm"
className="h-auto rounded-full px-3 py-1 text-xs"
onClick={() => setPrompt(keyword)}
>
{keyword}
</Button>
))}
</div> </div>
</Button> )}
<div className={cn('space-y-2', !prompt.trim() && 'invisible')}>
<Button
onClick={handleSend}
className={cn('w-full rounded-full')}
size="lg"
variant="outline"
disabled={!prompt.trim() || isGeneratingMeme}
>
{isGeneratingMeme ? (
<Spinner className="text-primary h-4 w-4" />
) : (
<>
Generate Meme
<div className="flex items-center gap-1">
<CoinIcon></CoinIcon> 2
</div>
</>
)}
</Button>
<div className="text-muted-foreground text-center text-xs">
A new meme costs 1 credit for AI captions & 1 credit for AI background.{' '}
</div>
</div>
</div> </div>
</div> </div>
</SheetContent> </SheetContent>

View File

@@ -20,6 +20,11 @@ const useMediaStore = create(
currentCaption: 'I am chicken rice', currentCaption: 'I am chicken rice',
watermarked: true, watermarked: true,
keywords: [],
isLoadingAIHints: false,
isGeneratingMeme: false,
setCurrentTab: (tab) => { setCurrentTab: (tab) => {
set({ currentTab: tab }); set({ currentTab: tab });
}, },
@@ -42,6 +47,29 @@ const useMediaStore = create(
set({ selectedBackground: null }); set({ selectedBackground: null });
}, },
// Fetch AI hints
fetchAIHints: async () => {
set({ isLoadingAIHints: true });
try {
const response = await axiosInstance.post(route('api.ai_hints'));
set({
keywords: response.data.success?.data?.keywords || [],
});
return response.data;
} catch (error) {
console.error(route('api.ai_hints'));
console.error('Error fetching AI hints:', error);
toast.error('Failed to fetch AI hints');
} finally {
set({ isLoadingAIHints: false });
}
},
// Clear keywords
clearKeywords: () => {
set({ keywords: [] });
},
init: async () => { init: async () => {
try { try {
const response = await axiosInstance.post(route('api.app.init')); const response = await axiosInstance.post(route('api.app.init'));
@@ -62,6 +90,31 @@ const useMediaStore = create(
} }
}, },
generateMeme: async (prompt) => {
set({ isGeneratingMeme: true });
try {
const response = await axiosInstance.post(route('api.user.generate_meme', { prompt: prompt }));
if (response?.data?.success?.data?.generate) {
set({
currentCaption: response.data.success.data.generate.caption,
selectedMeme: response.data.success.data.generate.meme,
selectedBackground: response.data.success.data.generate.background,
});
} else {
throw 'Invalid API response';
}
return response.data;
} catch (error) {
console.error(route('api.app.generate_meme'));
console.error('Error generating meme:', error);
toast.error('Failed to generate meme');
} finally {
set({ isGeneratingMeme: false });
}
},
// Fetch memes (overlays) // Fetch memes (overlays)
fetchMemes: async () => { fetchMemes: async () => {
set({ isFetchingMemes: true }); set({ isFetchingMemes: true });

View File

@@ -34,6 +34,8 @@
Route::post('/premium-export/request', [UserExportController::class, 'premiumExportRequest'])->name('api.user.premium_export.request'); Route::post('/premium-export/request', [UserExportController::class, 'premiumExportRequest'])->name('api.user.premium_export.request');
Route::post('/premium-export/complete', [UserExportController::class, 'premiumExportComplete'])->name('api.user.premium_export.complete'); Route::post('/premium-export/complete', [UserExportController::class, 'premiumExportComplete'])->name('api.user.premium_export.complete');
Route::post('generate_meme', [UserAIController::class, 'generateMeme'])->name('api.user.generate_meme');
}); });
}); });