prepare('SELECT email, username, credits, email_verified_at FROM users WHERE id = :id'); $userStmt->execute(['id' => $_SESSION['user_id']]); $userRow = $userStmt->fetch(PDO::FETCH_ASSOC); $userCredits = (int) ($userRow['credits'] ?? 0); $isEmailVerified = !empty($userRow['email_verified_at']); $verificationEmailSent = false; $verificationResendError = null; if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'resend_verification') { if (!csrf_verify($_POST['csrf_token'] ?? null)) { $verificationResendError = 'Your session has expired. Please refresh the page and try again.'; } elseif ($isEmailVerified) { $verificationResendError = 'Your email is already verified.'; } elseif ( isset($_SESSION['last_verification_resend_at']) && (time() - $_SESSION['last_verification_resend_at']) < VERIFICATION_RESEND_COOLDOWN_SECONDS ) { $verificationResendError = 'Please wait a minute before requesting another verification email.'; } else { $verifyToken = createTicket((int) $_SESSION['user_id'], 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS); $appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/'); $verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken); sendEmailVerificationEmail($userRow['email'], $userRow['username'], $verifyLink); $_SESSION['last_verification_resend_at'] = time(); $verificationEmailSent = true; } } $pdo->exec('CREATE TABLE IF NOT EXISTS critters ( id INTEGER PRIMARY KEY AUTOINCREMENT, noun_id INTEGER NOT NULL REFERENCES nouns(id), adjective_id INTEGER NOT NULL REFERENCES adjectives(id), verb_id INTEGER NOT NULL REFERENCES verbs(id), location_id INTEGER NOT NULL REFERENCES locations(id), author_id INTEGER NOT NULL REFERENCES users(id), image TEXT, isFavorite INTEGER NOT NULL DEFAULT 0, isArchived INTEGER NOT NULL DEFAULT 0, datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); $critterColumns = $pdo->query('PRAGMA table_info(critters)')->fetchAll(PDO::FETCH_COLUMN, 1); if (!in_array('author_id', $critterColumns, true)) { $pdo->exec('ALTER TABLE critters ADD COLUMN author_id INTEGER REFERENCES users(id)'); } if (!in_array('image', $critterColumns, true)) { $pdo->exec('ALTER TABLE critters ADD COLUMN image TEXT'); } if (!in_array('isFavorite', $critterColumns, true)) { $pdo->exec('ALTER TABLE critters ADD COLUMN isFavorite INTEGER NOT NULL DEFAULT 0'); } if (!in_array('isArchived', $critterColumns, true)) { $pdo->exec('ALTER TABLE critters ADD COLUMN isArchived INTEGER NOT NULL DEFAULT 0'); } if (!in_array('datetime', $critterColumns, true)) { $pdo->exec('ALTER TABLE critters ADD COLUMN datetime TEXT'); $pdo->exec("UPDATE critters SET datetime = CURRENT_TIMESTAMP WHERE datetime IS NULL"); } $pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_noun_id ON critters (noun_id)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_adjective_id ON critters (adjective_id)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_verb_id ON critters (verb_id)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_location_id ON critters (location_id)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_author_id ON critters (author_id)'); function countCrittersCreatedTodayByAuthor(PDO $pdo, int $authorId): int { $stmt = $pdo->prepare("SELECT COUNT(*) FROM critters WHERE author_id = :author_id AND date(datetime) = date('now')"); $stmt->execute(['author_id' => $authorId]); return (int) $stmt->fetchColumn(); } function secondsSinceLastCritterByAuthor(PDO $pdo, int $authorId): ?int { $stmt = $pdo->prepare("SELECT CAST(strftime('%s', 'now') - strftime('%s', datetime) AS INTEGER) FROM critters WHERE author_id = :author_id ORDER BY datetime DESC LIMIT 1"); $stmt->execute(['author_id' => $authorId]); $seconds = $stmt->fetchColumn(); return $seconds === false ? null : (int) $seconds; } $dailyLimitReached = countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT; function isAllowedReplicateUrl(string $url): bool { $parts = parse_url($url); if ($parts === false || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) { return false; } $host = strtolower($parts['host']); return $host === 'api.replicate.com' || $host === 'replicate.delivery' || substr($host, -strlen('.replicate.delivery')) === '.replicate.delivery'; } function getValueById(PDO $pdo, string $table, $id): string { $stmt = $pdo->prepare("SELECT value FROM {$table} WHERE id = :id"); $stmt->execute(['id' => $id]); return (string) $stmt->fetchColumn(); } $adjectives = $pdo->query('SELECT id, value FROM adjectives ORDER BY value')->fetchAll(PDO::FETCH_ASSOC); $nouns = $pdo->query('SELECT id, value FROM nouns ORDER BY value')->fetchAll(PDO::FETCH_ASSOC); $verbs = $pdo->query('SELECT id, value FROM verbs ORDER BY value')->fetchAll(PDO::FETCH_ASSOC); $locations = $pdo->query('SELECT id, value FROM locations ORDER BY value')->fetchAll(PDO::FETCH_ASSOC); $fields = [ 'adjective_id' => ['table' => 'adjectives', 'label' => 'adjective'], 'noun_id' => ['table' => 'nouns', 'label' => 'noun'], 'verb_id' => ['table' => 'verbs', 'label' => 'verb'], 'location_id' => ['table' => 'locations', 'label' => 'location'], ]; $errors = []; $selected = [ 'adjective_id' => '', 'noun_id' => '', 'verb_id' => '', 'location_id' => '', ]; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { foreach ($fields as $field => $info) { $value = $_GET[$field] ?? ''; if ($value !== '' && ctype_digit((string) $value) && rowExistsById($pdo, $info['table'], $value)) { $selected[$field] = (int) $value; } } $hasRemixParams = $selected['adjective_id'] !== '' || $selected['noun_id'] !== '' || $selected['verb_id'] !== '' || $selected['location_id'] !== ''; if (!$hasRemixParams) { $optionLists = [ 'adjective_id' => $adjectives, 'noun_id' => $nouns, 'verb_id' => $verbs, 'location_id' => $locations, ]; foreach ($optionLists as $field => $rows) { if (!empty($rows)) { $selected[$field] = (int) $rows[array_rand($rows)]['id']; } } } } if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitReached) { if (!csrf_verify($_POST['csrf_token'] ?? null)) { $errors['csrf'] = 'Your session has expired. Please refresh the page and try again.'; } foreach ($fields as $field => $info) { $value = $_POST[$field] ?? ''; $selected[$field] = $value; if ($value === '' || !ctype_digit((string) $value) || !rowExistsById($pdo, $info['table'], $value)) { $errors[$field] = 'Please choose a valid ' . $info['label'] . '.'; } } $creditReserved = false; $critterId = null; if (empty($errors)) { // Hold SQLite's single write lock across the daily-limit check, the // cooldown check, the credit reservation, and the placeholder insert // so concurrent requests can't all read the same stale count/cooldown // before any of them commits. The row is inserted (image NULL) here, // before the slow Replicate call, so it immediately counts toward the // daily limit and cooldown for any other request racing in. $pdo->exec('BEGIN IMMEDIATE'); try { if (countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT) { $dailyLimitReached = true; } $cooldownRemaining = 0; $secondsSinceLast = secondsSinceLastCritterByAuthor($pdo, (int) $_SESSION['user_id']); if ($secondsSinceLast !== null && $secondsSinceLast < CRITTER_COOLDOWN_SECONDS) { $cooldownRemaining = CRITTER_COOLDOWN_SECONDS - $secondsSinceLast; } if (!$dailyLimitReached && $cooldownRemaining > 0) { $errors['cooldown'] = "Please wait {$cooldownRemaining} more second(s) before creating another critter."; } if (!$dailyLimitReached && empty($errors)) { // Atomically reserve one credit in the same statement that checks for it, so // concurrent requests can't all read "1 credit" before any of them writes the // deduction. SQLite serializes writes, so only as many of these UPDATEs can // match "credits > 0" as there are credits actually available; everyone else // gets 0 rows affected and is turned away before the expensive Replicate call. $reserveStmt = $pdo->prepare('UPDATE users SET credits = credits - 1 WHERE id = :id AND credits > 0'); $reserveStmt->execute(['id' => $_SESSION['user_id']]); $creditReserved = $reserveStmt->rowCount() === 1; if (!$creditReserved) { $userCredits = 0; $errors['credits'] = 'You do not have enough credits to create a critter.'; } } if ($creditReserved) { $insertStmt = $pdo->prepare('INSERT INTO critters (noun_id, adjective_id, verb_id, location_id, author_id, image, datetime) VALUES (:noun_id, :adjective_id, :verb_id, :location_id, :author_id, NULL, :datetime)'); $insertStmt->execute([ 'noun_id' => $selected['noun_id'], 'adjective_id' => $selected['adjective_id'], 'verb_id' => $selected['verb_id'], 'location_id' => $selected['location_id'], 'author_id' => $_SESSION['user_id'], 'datetime' => gmdate('Y-m-d H:i:s'), ]); $critterId = (int) $pdo->lastInsertId(); } $pdo->exec('COMMIT'); } catch (Throwable $e) { $pdo->exec('ROLLBACK'); throw $e; } } if ($creditReserved && $critterId !== null) { $adjectiveValue = getValueById($pdo, 'adjectives', $selected['adjective_id']); $nounValue = getValueById($pdo, 'nouns', $selected['noun_id']); $verbValue = getValueById($pdo, 'verbs', $selected['verb_id']); $locationValue = getValueById($pdo, 'locations', $selected['location_id']); $prompt = "A {$adjectiveValue} {$nounValue} {$verbValue} {$locationValue} in a high quality 3d animation art style. The image should be vibrant and colorful, capturing the essence of the action and setting."; $ch = curl_init('https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt'); curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . getenv('REPLICATE_API_TOKEN'), 'Content-Type: application/json', 'Prefer: wait', ]); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'input' => [ 'prompt' => $prompt, ], ])); $replicateResponse = curl_exec($ch); $imageUrl = null; if ($replicateResponse !== false) { $prediction = json_decode($replicateResponse, true); $pollAttempt = 0; while ( isset($prediction['status']) && !in_array($prediction['status'], ['succeeded', 'failed', 'canceled'], true) && $pollAttempt < 30 ) { $pollUrl = $prediction['urls']['get'] ?? ''; if (!is_string($pollUrl) || !isAllowedReplicateUrl($pollUrl)) { $prediction['status'] = 'failed'; break; } sleep(2); $pollCh = curl_init($pollUrl); curl_setopt($pollCh, CURLOPT_RETURNTRANSFER, true); curl_setopt($pollCh, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt'); curl_setopt($pollCh, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); curl_setopt($pollCh, CURLOPT_TIMEOUT, 15); curl_setopt($pollCh, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . getenv('REPLICATE_API_TOKEN'), ]); $pollResponse = curl_exec($pollCh); if ($pollResponse !== false) { $prediction = json_decode($pollResponse, true); } $pollAttempt++; } if (($prediction['status'] ?? null) === 'succeeded') { $output = $prediction['output'] ?? null; $imageUrl = is_array($output) ? ($output[0] ?? null) : $output; } } $imageFilename = null; if (is_string($imageUrl) && isAllowedReplicateUrl($imageUrl)) { $imageCh = curl_init($imageUrl); curl_setopt($imageCh, CURLOPT_RETURNTRANSFER, true); curl_setopt($imageCh, CURLOPT_CAINFO, '/etc/ssl/certs/ca-certificates.crt'); curl_setopt($imageCh, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); curl_setopt($imageCh, CURLOPT_TIMEOUT, 30); curl_setopt($imageCh, CURLOPT_MAXFILESIZE, 15 * 1024 * 1024); $imageData = curl_exec($imageCh); if ($imageData !== false && strlen($imageData) > 0 && strlen($imageData) <= 15 * 1024 * 1024) { $imageInfo = @getimagesizefromstring($imageData); // Extension is derived from the detected mime type below, so it always // matches the actual bytes on disk instead of assuming every allowed // type is webp. $imageExtensionsByMime = [ 'image/webp' => 'webp', 'image/png' => 'png', 'image/jpeg' => 'jpg', ]; $detectedMime = $imageInfo['mime'] ?? ''; if ($imageInfo !== false && isset($imageExtensionsByMime[$detectedMime])) { $imagesDir = __DIR__ . '/critter-images'; if (!is_dir($imagesDir)) { mkdir($imagesDir, 0755, true); } $candidateFilename = bin2hex(random_bytes(16)) . '.' . $imageExtensionsByMime[$detectedMime]; $bytesWritten = file_put_contents($imagesDir . '/' . $candidateFilename, $imageData); if ($bytesWritten !== false) { $imageFilename = $candidateFilename; } } } } if ($imageFilename === null) { // Every unsuccessful path above (request failure, failed/canceled prediction, // disallowed or missing image URL, oversized or non-image download, or a failed // write to disk) leaves $imageFilename null, so this single check refunds the // reserved credit for all of them instead of only the initial request-failure case. // The placeholder row is removed too, so a failed attempt doesn't also cost the // user one of their daily critters or start a cooldown — refunding the credit but // leaving the row behind would silently charge the daily/cooldown quota for // something that, as far as the user is concerned, never happened. $refundStmt = $pdo->prepare('UPDATE users SET credits = credits + 1 WHERE id = :id'); $refundStmt->execute(['id' => $_SESSION['user_id']]); $deleteStmt = $pdo->prepare('DELETE FROM critters WHERE id = :id'); $deleteStmt->execute(['id' => $critterId]); } else { $updateStmt = $pdo->prepare('UPDATE critters SET image = :image WHERE id = :id'); $updateStmt->execute(['image' => $imageFilename, 'id' => $critterId]); } header('Location: /'); exit; } } function renderOptions(array $rows, $selectedId): void { foreach ($rows as $row) { $isSelected = (string) $selectedId === (string) $row['id']; echo ''; } } $pageTitle = 'Create Critter'; ?>

Verify your email to start creating

We sent a verification link to your email when you signed up. Verify it to unlock your free credits.

Verification email sent! Check your inbox.

Critter limit reached for the day

Critter limit reached for the day, please come back tomorrow.

Out of credits

You have 0 credits remaining, so you can't create a new critter right now.

Buy Credits

Imagine · Mix · Create

Create Your New Critter

Pick a recipe and we'll bring it to life.