prepare('SELECT credits FROM users WHERE id = :id');
$creditsStmt->execute(['id' => $_SESSION['user_id']]);
$userCredits = (int) $creditsStmt->fetchColumn();
$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 countCrittersCreatedToday(PDO $pdo): int
{
return (int) $pdo->query("SELECT COUNT(*) FROM critters WHERE date(datetime) = date('now')")->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 = countCrittersCreatedToday($pdo) >= DAILY_CRITTER_LIMIT;
$cooldownRemaining = 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$secondsSinceLast = secondsSinceLastCritterByAuthor($pdo, (int) $_SESSION['user_id']);
if ($secondsSinceLast !== null && $secondsSinceLast < CRITTER_COOLDOWN_SECONDS) {
$cooldownRemaining = CRITTER_COOLDOWN_SECONDS - $secondsSinceLast;
}
}
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) {
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'] . '.';
}
}
if (empty($errors) && countCrittersCreatedToday($pdo) >= DAILY_CRITTER_LIMIT) {
$dailyLimitReached = true;
}
if (empty($errors) && $cooldownRemaining > 0) {
$errors['cooldown'] = "Please wait {$cooldownRemaining} more second(s) before creating another critter.";
}
$creditReserved = false;
if (empty($errors) && !$dailyLimitReached) {
// 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 (empty($errors) && !$dailyLimitReached && $creditReserved) {
$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;
}
} else {
// The Replicate request never went through, so give the reserved credit back.
$refundStmt = $pdo->prepare('UPDATE users SET credits = credits + 1 WHERE id = :id');
$refundStmt->execute(['id' => $_SESSION['user_id']]);
}
$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);
$allowedImageTypes = ['image/webp', 'image/png', 'image/jpeg'];
if ($imageInfo !== false && in_array($imageInfo['mime'] ?? '', $allowedImageTypes, true)) {
$imagesDir = __DIR__ . '/critter-images';
if (!is_dir($imagesDir)) {
mkdir($imagesDir, 0755, true);
}
$imageFilename = bin2hex(random_bytes(16)) . '.webp';
file_put_contents($imagesDir . '/' . $imageFilename, $imageData);
}
}
}
$stmt = $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, :image, :datetime)');
$stmt->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'],
'image' => $imageFilename,
'datetime' => gmdate('Y-m-d H:i:s'),
]);
header('Location: /');
exit;
}
}
function renderOptions(array $rows, $selectedId): void
{
foreach ($rows as $row) {
$isSelected = (string) $selectedId === (string) $row['id'];
echo '';
}
}
$pageTitle = 'Create Critter';
?>
Critter limit reached for the day, please come back tomorrow.
You have 0 credits remaining, so you can't create a new critter right now.
Buy Credits
Imagine · Mix · Create
Pick a recipe and we'll bring it to life.
Mixing colors…
This can take up to a minute — hang tight!