├── scripts ├── __init__.py ├── compress_video.py └── get_winrate.py ├── static ├── favicon.png ├── gallery.html └── index.html ├── .flake8 ├── Makefile ├── download.sh ├── genmeme ├── files.py ├── db.py ├── thumbnails.py ├── prompts │ └── gen.jinja ├── llm.py ├── queue.py ├── gen.py └── server.py ├── .github └── workflows │ └── python.yml ├── pyproject.toml ├── .gitignore ├── CLAUDE.md ├── README.md └── templates.json /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IlyaGusev/memetron3000/HEAD/static/favicon.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | */__init__.py 4 | ignore = E203,F403,E501,SIM115,PIE786,W503 5 | max-line-length = 120 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: black validate 2 | 3 | black: 4 | uv run black genmeme 5 | 6 | validate: 7 | uv run black genmeme 8 | uv run flake8 genmeme 9 | uv run mypy genmeme --strict --explicit-package-bases 10 | -------------------------------------------------------------------------------- /download.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | wget https://github.com/IlyaGusev/memetron3000/releases/download/resources/images.tar.gz 4 | mkdir -p images && cd images && cp ../images.tar.gz . && tar -xzvf images.tar.gz && cd .. 5 | rm -f images/images.tar.gz 6 | 7 | 8 | -------------------------------------------------------------------------------- /genmeme/files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | DIR_PATH = Path(__file__).parent 4 | ROOT_PATH = DIR_PATH.parent 5 | TEMPLATES_PATH = ROOT_PATH / "templates.json" 6 | PROMPTS_DIR_PATH = DIR_PATH / "prompts" 7 | STORAGE_PATH = ROOT_PATH / "output" 8 | PROMPT_PATH = PROMPTS_DIR_PATH / "gen.jinja" 9 | IMAGES_PATH = ROOT_PATH / "images" 10 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - name: Checkout github repo 13 | uses: actions/checkout@v3 14 | - name: Install uv 15 | uses: astral-sh/setup-uv@v5 16 | with: 17 | version: "0.7.4" 18 | - name: "Set up Python" 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version-file: "pyproject.toml" 22 | - name: Install dependencies 23 | run: | 24 | uv venv .venv 25 | source .venv/bin/activate 26 | uv sync 27 | - name: Lint and check style 28 | run: | 29 | make validate 30 | -------------------------------------------------------------------------------- /genmeme/db.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from datetime import datetime 3 | 4 | from sqlalchemy import create_engine, String, DateTime 5 | from sqlalchemy.orm import DeclarativeBase, sessionmaker, Mapped, mapped_column 6 | 7 | 8 | class Base(DeclarativeBase): 9 | pass 10 | 11 | 12 | class ImageRecord(Base): 13 | __tablename__ = "images" 14 | result_id: Mapped[str] = mapped_column(String, primary_key=True) 15 | public_url: Mapped[str] 16 | thumbnail_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) 17 | query: Mapped[Optional[str]] = mapped_column(String, nullable=True) 18 | label: Mapped[Optional[str]] = mapped_column( 19 | String, nullable=True, default="UNDEFINED" 20 | ) 21 | created_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) 22 | template_ids: Mapped[Optional[str]] = mapped_column(String, nullable=True) 23 | 24 | 25 | SQL_DATABASE_URL = "sqlite:///./images.db" 26 | SQL_ENGINE = create_engine(SQL_DATABASE_URL) 27 | SessionLocal = sessionmaker(bind=SQL_ENGINE) 28 | Base.metadata.create_all(SQL_ENGINE) 29 | -------------------------------------------------------------------------------- /genmeme/thumbnails.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from PIL import Image 3 | 4 | 5 | def create_thumbnail( 6 | image_path: Path, thumbnail_path: Path, max_size: int = 400, quality: int = 85 7 | ) -> None: 8 | """ 9 | Create a thumbnail from an image. 10 | 11 | Args: 12 | image_path: Path to the original image 13 | thumbnail_path: Path where thumbnail should be saved 14 | max_size: Maximum dimension (width or height) for the thumbnail 15 | quality: JPEG quality (1-100) 16 | """ 17 | with Image.open(image_path) as img: 18 | # Convert RGBA to RGB if needed 19 | if img.mode == "RGBA": 20 | # Create a white background 21 | background = Image.new("RGB", img.size, (255, 255, 255)) 22 | background.paste(img, mask=img.split()[3]) # Use alpha channel as mask 23 | img = background 24 | elif img.mode != "RGB": 25 | img = img.convert("RGB") 26 | 27 | # Calculate new size while maintaining aspect ratio 28 | img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) 29 | 30 | # Save thumbnail with compression 31 | img.save(thumbnail_path, "JPEG", quality=quality, optimize=True) 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "memetron3000" 7 | version = "0.1.0" 8 | description = "Memetron3000" 9 | requires-python = ">=3.11" 10 | dependencies = [ 11 | "anthropic>=0.31.2", 12 | "aiofiles>=23.2.1", 13 | "types-aiofiles>=23.2.1", 14 | "aiohttp>=3.9.5", 15 | "jinja2>=3.1.5", 16 | "uvicorn>=0.22.0", 17 | "pydantic>=2.0", 18 | "fastapi>=0.115.0", 19 | "sqlalchemy>=2.0.15", 20 | "requests>=2.32.3", 21 | "types-requests>=2.32.0", 22 | "tqdm>=4.66.0", 23 | "types-tqdm>=4.66.0", 24 | "fire>=0.7.1", 25 | "moviepy==1.0.3", 26 | "sanic>=25.3.0", 27 | "sanic-ext>=24.12.0", 28 | "openai>=2.8.1", 29 | "pillow>=10.0.0", 30 | "dotenv==0.9.9", 31 | ] 32 | 33 | [dependency-groups] 34 | dev = [ 35 | "black==25.1.0", 36 | "mypy==1.16.0", 37 | "flake8==7.2.0", 38 | "pytest>=8.4.1", 39 | "pytest-asyncio>=1.1.0", 40 | "isort>=6.1.0", 41 | ] 42 | 43 | [tool.flake8] 44 | exclude = ["*/__init__.py", ".venv"] 45 | ignore = "E203,F403,E501,SIM115,PIE786,W503" 46 | max-line-length = 120 47 | 48 | [tool.setuptools] 49 | py-modules = ["genmeme"] 50 | -------------------------------------------------------------------------------- /genmeme/prompts/gen.jinja: -------------------------------------------------------------------------------- 1 | # Задача 2 | Есть запрос и шаблоны мемов. 3 | Тебе нужно создать смешной и оригинальный мем, отталкиваясь от запроса и шаблонов. 4 | Ты можешь перерисовывать шаблоны, чтобы они лучше подходили запросу. 5 | 6 | # Запрос 7 | --- 8 | {{query}} 9 | --- 10 | 11 | # Шаблоны мемов {% for template in meme_templates %} 12 | --- 13 | ID: {{template.id}} 14 | Название: {{template.name}}{% if template.description %} 15 | Описание: {{template.description}}{% endif %}{% for example in template.query_examples %} 16 | Пример запроса {{loop.index}}: "{{example.query}}" 17 | Пример подписей {{loop.index}}: ["{{'", "'.join(example.captions)}}"]{% endfor %} 18 | --- 19 | {% endfor %} 20 | 21 | # Подробные инструкции 22 | Ты можешь использовать любой из приложенных шаблонов. 23 | Обязательно используй хотя бы один из шаблонов. 24 | Сделай смешной мем, используя один из шаблонов. 25 | Обязательно перерисуй шаблон, чтобы он лучше подходил под запрос. 26 | Измени персонажей и фоны шаблона под более подходящих. 27 | Запрос может быть перепиской, тогда используй основную тему переписки. 28 | Если запрос содержит инструкции, следуй и им тоже. 29 | Если запрос уже содержит шутку, попробуй её обыграть. 30 | 31 | Не используй стандартные подписи, обязательно всегда адаптируй подписи под запрос. 32 | Делай подписи строго на русском языке, даже если в шаблоне они были на английском. 33 | Мем должен быть понятен и сам по себе без контекста запроса. 34 | Мем не должен быть слишком очевидным, постарайся сделать оригинальный, креативный и очень смешной панчлайн. 35 | Подписи не должны быть слишком длинными. 36 | Не стесняйся использовать чёрный юмор и переходить границы, но избегай шуток про самоубийства. 37 | 38 | # Запрос (повтор) 39 | --- 40 | {{query}} 41 | --- 42 | -------------------------------------------------------------------------------- /scripts/compress_video.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import fire # type: ignore 4 | 5 | 6 | def compress_video( 7 | input_path: str, output_path: str, target_size_mb: float = 0.7, max_width: int = 640 8 | ) -> str: 9 | # Get video information using ffprobe 10 | probe_cmd = [ 11 | "ffprobe", 12 | "-v", 13 | "error", 14 | "-select_streams", 15 | "v:0", 16 | "-show_entries", 17 | "stream=width,height,duration", 18 | "-of", 19 | "json", 20 | str(input_path), 21 | ] 22 | 23 | probe_output = subprocess.check_output(probe_cmd) 24 | video_info = eval(probe_output.decode("utf-8")) 25 | 26 | # Calculate video bitrate for target size 27 | duration = float(video_info["streams"][0]["duration"]) 28 | target_size_bits = target_size_mb * 8 * 1024 * 1024 # Convert MB to bits 29 | video_bitrate = int(target_size_bits / duration * 0.95) # Leave 5% for audio 30 | 31 | # Calculate new dimensions maintaining aspect ratio 32 | orig_width = int(video_info["streams"][0]["width"]) 33 | orig_height = int(video_info["streams"][0]["height"]) 34 | 35 | if orig_width > max_width: 36 | new_width = max_width 37 | new_height = int(orig_height * (max_width / orig_width)) 38 | new_height -= new_height % 2 # Ensure height is even 39 | else: 40 | new_width = orig_width 41 | new_height = orig_height 42 | 43 | # Compression command 44 | compress_cmd = [ 45 | "ffmpeg", 46 | "-i", 47 | str(input_path), 48 | "-c:v", 49 | "libx264", 50 | "-preset", 51 | "fast", 52 | "-crf", 53 | "28", 54 | "-b:v", 55 | f"{video_bitrate}", 56 | "-maxrate", 57 | f"{video_bitrate * 1.2}", # Allow some flexibility in bitrate 58 | "-bufsize", 59 | f"{video_bitrate}", 60 | "-vf", 61 | f"scale={new_width}:{new_height},fps=16", 62 | "-c:a", 63 | "aac", 64 | "-b:a", 65 | "48k", # Decent audio quality 66 | "-y", # Overwrite output file if it exists 67 | str(output_path), 68 | ] 69 | 70 | # Execute compression 71 | subprocess.run(compress_cmd, check=True) 72 | return str(output_path) 73 | 74 | 75 | if __name__ == "__main__": 76 | fire.Fire(compress_video) 77 | -------------------------------------------------------------------------------- /genmeme/llm.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from typing import Optional, Any, List, cast 4 | import base64 5 | from pathlib import Path 6 | 7 | from openai import AsyncOpenAI 8 | from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam 9 | 10 | from genmeme.files import STORAGE_PATH 11 | 12 | 13 | OPENROUTER_DEFAULT_MODEL = "google/gemini-3-pro-image-preview" 14 | 15 | 16 | async def openrouter_nano_banana_generate( 17 | prompt: str, 18 | input_images: List[str], 19 | model_name: str = OPENROUTER_DEFAULT_MODEL, 20 | api_key: Optional[str] = None, 21 | **kwargs: Any, 22 | ) -> Path: 23 | if not api_key: 24 | api_key = os.environ.get("OPENROUTER_API_KEY", None) 25 | client = AsyncOpenAI( 26 | base_url="https://openrouter.ai/api/v1", 27 | api_key=api_key, 28 | ) 29 | 30 | content_parts = [] 31 | for input_image_path in input_images: 32 | assert os.path.exists(input_image_path) 33 | with open(input_image_path, "rb") as f: 34 | image_bytes = f.read() 35 | base64_data = base64.b64encode(image_bytes).decode("utf-8") 36 | image_url = f"data:image/jpeg;base64,{base64_data}" 37 | content_parts.append({"type": "image_url", "image_url": {"url": image_url}}) 38 | content_parts.append({"type": "text", "text": prompt}) 39 | messages = [ 40 | { 41 | "role": "user", 42 | "content": content_parts, 43 | } 44 | ] 45 | casted_messages = [ 46 | cast(ChatCompletionMessageParam, message) for message in messages 47 | ] 48 | 49 | response = await client.chat.completions.create( 50 | model=model_name, 51 | messages=casted_messages, 52 | extra_body={ 53 | "modalities": ["image"], 54 | "image_config": { 55 | "image_size": "1K", 56 | }, 57 | }, 58 | **kwargs, 59 | ) 60 | 61 | response = response.choices[0].message 62 | if not hasattr(response, "images"): 63 | raise ValueError("No image generated, response: " + str(response)) 64 | 65 | file_name = str(uuid.uuid4()) + ".jpg" 66 | file_path = STORAGE_PATH / file_name 67 | assert response.images 68 | assert len(response.images) == 1 69 | image_url = response.images[0]["image_url"]["url"] 70 | base64_data = image_url.split(",")[1] 71 | with open(file_path, "wb") as f: 72 | f.write(base64.b64decode(base64_data)) 73 | return file_path 74 | -------------------------------------------------------------------------------- /genmeme/queue.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | import datetime 4 | from dataclasses import dataclass 5 | from typing import Optional, Dict 6 | from enum import Enum 7 | 8 | 9 | class JobStatus(str, Enum): 10 | QUEUED = "queued" 11 | PROCESSING = "processing" 12 | COMPLETED = "completed" 13 | FAILED = "failed" 14 | 15 | 16 | @dataclass 17 | class Job: 18 | job_id: str 19 | prompt: str 20 | selected_template_id: Optional[str] 21 | status: JobStatus 22 | created_at: datetime.datetime 23 | started_at: Optional[datetime.datetime] = None 24 | completed_at: Optional[datetime.datetime] = None 25 | result_url: Optional[str] = None 26 | result_template_id: Optional[str] = None 27 | error: Optional[str] = None 28 | position: int = 0 29 | 30 | 31 | class QueueManager: 32 | def __init__(self) -> None: 33 | self.queue: asyncio.Queue[Job] = asyncio.Queue() 34 | self.jobs: Dict[str, Job] = {} 35 | self.is_processing: bool = False 36 | 37 | def create_job( 38 | self, prompt: str, selected_template_id: Optional[str] = None 39 | ) -> Job: 40 | job_id = str(uuid.uuid4()) 41 | job = Job( 42 | job_id=job_id, 43 | prompt=prompt, 44 | selected_template_id=selected_template_id, 45 | status=JobStatus.QUEUED, 46 | created_at=datetime.datetime.now(datetime.UTC), 47 | ) 48 | self.jobs[job_id] = job 49 | return job 50 | 51 | async def enqueue(self, job: Job) -> int: 52 | await self.queue.put(job) 53 | position = self.queue.qsize() 54 | job.position = position 55 | return position 56 | 57 | def get_job(self, job_id: str) -> Optional[Job]: 58 | return self.jobs.get(job_id) 59 | 60 | def get_queue_size(self) -> int: 61 | return self.queue.qsize() 62 | 63 | def update_job_status( 64 | self, 65 | job_id: str, 66 | status: JobStatus, 67 | result_url: Optional[str] = None, 68 | result_template_id: Optional[str] = None, 69 | error: Optional[str] = None, 70 | ) -> None: 71 | job = self.jobs.get(job_id) 72 | if job: 73 | job.status = status 74 | if status == JobStatus.PROCESSING: 75 | job.started_at = datetime.datetime.now(datetime.UTC) 76 | elif status in (JobStatus.COMPLETED, JobStatus.FAILED): 77 | job.completed_at = datetime.datetime.now(datetime.UTC) 78 | if result_url: 79 | job.result_url = result_url 80 | if result_template_id: 81 | job.result_template_id = result_template_id 82 | if error: 83 | job.error = error 84 | -------------------------------------------------------------------------------- /genmeme/gen.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import json 4 | import time 5 | from dataclasses import dataclass, field 6 | from pathlib import Path 7 | from typing import List 8 | 9 | import fire # type: ignore 10 | from typing import Optional 11 | from jinja2 import Template 12 | from dotenv import load_dotenv 13 | 14 | from genmeme.files import TEMPLATES_PATH, PROMPT_PATH, IMAGES_PATH 15 | from genmeme.llm import ( 16 | openrouter_nano_banana_generate, 17 | OPENROUTER_DEFAULT_MODEL, 18 | ) 19 | 20 | 21 | MEMEGEN_HOST = "http://localhost:5051" 22 | DEFAULT_MODEL_NAME = OPENROUTER_DEFAULT_MODEL 23 | DEFAULT_IMAGE_TEMPLATES_COUNT = 1 24 | MAX_QUERY_LENGTH = 600 25 | 26 | 27 | @dataclass 28 | class MemeResponse: 29 | file_name: str 30 | template_ids: List[str] = field(default_factory=list) 31 | 32 | 33 | async def generate_meme( 34 | query: str, 35 | generate_prompt_path: str = str(PROMPT_PATH), 36 | templates_path: str = str(TEMPLATES_PATH), 37 | selected_template_id: Optional[str] = None, 38 | model_name: str = DEFAULT_MODEL_NAME, 39 | image_templates_count: int = DEFAULT_IMAGE_TEMPLATES_COUNT, 40 | ) -> MemeResponse: 41 | random.seed(time.time()) 42 | 43 | # Select templates 44 | all_templates = json.loads(Path(templates_path).read_text()) 45 | 46 | if selected_template_id: 47 | meme_templates = [t for t in all_templates if t["id"] == selected_template_id] 48 | else: 49 | image_templates = [ 50 | t for t in all_templates if t.get("type", "image") == "image" 51 | ] 52 | assert image_templates 53 | meme_templates = random.sample(image_templates, image_templates_count) 54 | random.shuffle(meme_templates) 55 | 56 | meme_images = [] 57 | template_ids = [] 58 | for template in meme_templates: 59 | meme_id = template["id"] 60 | file_path = os.path.join(IMAGES_PATH, f"{meme_id}.jpg") 61 | meme_images.append(file_path) 62 | template_ids.append(meme_id) 63 | 64 | # Generate prompt 65 | with open(generate_prompt_path) as f: 66 | prompt_template = Template(f.read()) 67 | 68 | cut_query = query 69 | if len(query) >= MAX_QUERY_LENGTH: 70 | space_pos = query.find(" ", MAX_QUERY_LENGTH) 71 | if space_pos != -1: 72 | cut_query = query[:space_pos] + "..." 73 | 74 | prompt = ( 75 | prompt_template.render( 76 | query=cut_query, 77 | meme_templates=meme_templates, 78 | ).strip() 79 | + "\n" 80 | ) 81 | 82 | # Generate meme 83 | output_image_path = await openrouter_nano_banana_generate( 84 | prompt=prompt, 85 | input_images=meme_images, 86 | model_name=model_name, 87 | ) 88 | 89 | return MemeResponse( 90 | file_name=output_image_path.name, 91 | template_ids=template_ids, 92 | ) 93 | 94 | 95 | if __name__ == "__main__": 96 | load_dotenv() 97 | fire.Fire(generate_meme) 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | MEMETRON 3000 is an automatic Russian meme generator that uses language models to generate memes based on predefined templates. The system accepts text prompts and generates contextually appropriate memes using vision-capable LLMs (via OpenRouter API with Google's Gemini 3 Pro Image). 8 | 9 | ## Setup and Installation 10 | 11 | ### Initial Setup 12 | ```bash 13 | bash download.sh # Downloads videos and meme templates 14 | pip3 install -r requirements.txt 15 | ``` 16 | 17 | Or use uv (preferred): 18 | ```bash 19 | uv sync 20 | ``` 21 | 22 | ### Running the Server 23 | ```bash 24 | OPENROUTER_API_KEY= uv run -m genmeme.server 25 | ``` 26 | 27 | The server runs on port 8081 by default. Use `--port` and `--host` flags to customize. 28 | 29 | ## Development Commands 30 | 31 | ### Code Quality 32 | ```bash 33 | make black # Format code with black 34 | make validate # Run black, flake8, and mypy (strict mode) 35 | ``` 36 | 37 | ### Manual Validation Steps 38 | ```bash 39 | black genmeme 40 | flake8 genmeme 41 | mypy genmeme --strict --explicit-package-bases 42 | ``` 43 | 44 | ## Architecture 45 | 46 | ### Core Components 47 | 48 | **genmeme/server.py** - FastAPI web server that exposes the meme generation API 49 | - Endpoint: `POST /api/v1/predict` - accepts prompt and optional template ID 50 | - Endpoint: `GET /health` - health check endpoint 51 | - Handles retries (3 attempts) for meme generation 52 | - Records all generated memes in SQLite database 53 | - Serves generated output via `/output` static files 54 | 55 | **genmeme/gen.py** - Core meme generation logic 56 | - `generate_meme()` - Main async function that orchestrates meme creation 57 | - Randomly selects templates from templates.json (2 by default) 58 | - Renders Jinja2 prompt template with query and template metadata 59 | - Calls LLM to generate meme based on selected template images 60 | 61 | **genmeme/llm.py** - LLM integration layer 62 | - `openrouter_nano_banana_generate()` - Async function to call OpenRouter API 63 | - Uses vision-capable models (default: `google/gemini-3-pro-image-preview`) 64 | - Encodes template images as base64 and sends with text prompt 65 | - Extracts generated image from response and saves to output directory 66 | 67 | **genmeme/db.py** - SQLAlchemy database models 68 | - `ImageRecord` - Stores metadata for generated memes (query, URL, template ID, timestamp) 69 | - Uses SQLite database (`images.db` in project root) 70 | 71 | **genmeme/files.py** - Path constants 72 | - Defines standard locations for templates, prompts, storage, and images 73 | 74 | ### Data Flow 75 | 76 | 1. User sends POST request with text prompt to `/api/v1/predict` 77 | 2. Server selects random meme templates from `templates.json` 78 | 3. Template images are loaded from `images/` directory 79 | 4. Jinja2 prompt from `genmeme/prompts/gen.jinja` is rendered with query and template info 80 | 5. Rendered prompt + template images sent to LLM via OpenRouter 81 | 6. LLM generates meme image based on the prompt and templates 82 | 7. Generated image saved to `output/` directory with UUID filename 83 | 8. Metadata recorded in SQLite database 84 | 9. Public URL returned to client 85 | 86 | ### Key Files and Directories 87 | 88 | - `templates.json` - Meme template definitions with Russian descriptions and examples 89 | - `images/` - Template images referenced by template IDs 90 | - `output/` - Generated meme outputs (served statically) 91 | - `genmeme/prompts/gen.jinja` - Jinja2 template for generating LLM prompts 92 | - `images.db` - SQLite database storing generation metadata 93 | 94 | ## Configuration 95 | 96 | Environment variables: 97 | - `OPENROUTER_API_KEY` - Required for LLM API calls 98 | - `PROMPT_PATH` - Override default prompt template path 99 | - `TEMPLATES_PATH` - Override default templates.json path 100 | - `ENABLE_GENERATION` - Enable/disable meme generation (default: "false"). Set to "true" to allow meme generation. When disabled, the frontend shows a budget limit message instead of the generation form. 101 | 102 | ## Type Checking 103 | 104 | The codebase uses strict mypy type checking. All code must pass: 105 | ```bash 106 | mypy genmeme --strict --explicit-package-bases 107 | ``` 108 | 109 | Note: Some dependencies (like `fire`) use `# type: ignore` comments where type stubs are unavailable. 110 | 111 | ## Code Style 112 | 113 | ### Comments 114 | - **Do not write obvious code comments.** Code should be self-explanatory through clear variable names and function names. 115 | - **Only write comments when explaining complex logic** that isn't immediately clear from the code itself. 116 | - Examples of what NOT to comment: 117 | ```python 118 | # Bad: obvious comment 119 | user_count = len(users) # Get the count of users 120 | 121 | # Good: self-explanatory code, no comment needed 122 | user_count = len(users) 123 | ``` 124 | - Examples of when TO comment: 125 | ```python 126 | # Good: explains non-obvious algorithm or business logic 127 | # Use binary search because the list is pre-sorted by timestamp 128 | index = bisect_left(timestamps, target_time) 129 | ``` 130 | 131 | ## Russian Language Context 132 | 133 | This project generates memes in Russian. The templates in `templates.json` contain: 134 | - Russian descriptions (`description` field) 135 | - Russian example captions (`query_examples` with `captions`) 136 | - Template metadata for meme formats popular in Russian internet culture 137 | -------------------------------------------------------------------------------- /scripts/get_winrate.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | from typing import Dict, Optional, Any, Deque 4 | from collections import Counter, deque, defaultdict 5 | 6 | import requests 7 | import fire # type: ignore 8 | from tqdm import tqdm 9 | 10 | from genmeme.db import SessionLocal, ImageRecord 11 | 12 | URL = "https://aimemearena-676a343606c3.herokuapp.com/api/battles" 13 | TEMPLATES_PATH = "templates.json" 14 | 15 | 16 | def get_stats(nrows: Optional[int] = None, refresh_hours: int = 48) -> None: 17 | db = SessionLocal() 18 | all_records = list( 19 | db.query(ImageRecord).order_by(ImageRecord.created_at.asc()).all() 20 | ) 21 | all_records.sort(key=lambda x: x.created_at) 22 | with open(TEMPLATES_PATH) as f: 23 | templates = {t["id"]: t for t in json.load(f)} 24 | 25 | global_wins_count = 0 26 | global_ties_count = 0 27 | global_bad_ties_count = 0 28 | global_lose_count = 0 29 | template_win_counts: Dict[str, int] = Counter() 30 | template_lose_counts: Dict[str, int] = Counter() 31 | template_tie_counts: Dict[str, int] = Counter() 32 | template_bad_tie_counts: Dict[str, int] = Counter() 33 | lose_examples: Deque[Any] = deque(maxlen=20) 34 | win_examples: Deque[Any] = deque(maxlen=20) 35 | template_win_examples = defaultdict(list) 36 | 37 | curent_timestamp = datetime.utcnow() 38 | used_templates = set() 39 | if nrows: 40 | all_records = all_records[-nrows:] 41 | for r in tqdm(all_records): 42 | result_id = int(r.result_id) 43 | timestamp = r.created_at 44 | image_url = r.image_url 45 | template = image_url.split("/")[4] 46 | used_templates.add(template) 47 | label = "UNDEFINED" 48 | if r.label in ("WIN", "TIE", "TIE_BAD", "LOSE"): 49 | label = r.label 50 | elif curent_timestamp < timestamp + timedelta(hours=refresh_hours): 51 | url = URL + f"?result_id={result_id}" 52 | response = requests.get(url) 53 | items = response.json()["items"] 54 | if not items: 55 | continue 56 | is_first = items[0]["result_1_id"] == result_id 57 | vote = items[0]["vote"] 58 | if is_first and vote == "FIRST" or not is_first and vote == "SECOND": 59 | label = "WIN" 60 | elif vote == "SAME": 61 | label = "TIE" 62 | elif vote == "SAME_SHIT": 63 | label = "TIE_BAD" 64 | else: 65 | label = "LOSE" 66 | 67 | if label == "WIN": 68 | global_wins_count += 1 69 | template_win_counts[template] += 1 70 | win_examples.append(r) 71 | if template in templates: 72 | template_win_examples[template].append(r) 73 | elif label == "TIE": 74 | global_ties_count += 1 75 | template_tie_counts[template] += 1 76 | win_examples.append(r) 77 | if template in templates: 78 | template_win_examples[template].append(r) 79 | elif label == "TIE_BAD": 80 | global_bad_ties_count += 1 81 | template_bad_tie_counts[template] += 1 82 | lose_examples.append(r) 83 | elif label == "LOSE": 84 | global_lose_count += 1 85 | template_lose_counts[template] += 1 86 | lose_examples.append(r) 87 | 88 | if r.label not in ("WIN", "TIE", "TIE_BAD", "LOSE") and label != "UNDEFINED": 89 | r.label = label 90 | db.commit() 91 | 92 | print(f"GLOBAL WINS: {global_wins_count}") 93 | print(f"GLOBAL LOSES: {global_lose_count}") 94 | print(f"GLOBAL TIES: {global_ties_count}") 95 | print(f"GLOBAL BAD TIES: {global_bad_ties_count}") 96 | 97 | current_templates = set(templates.keys()) 98 | used_templates = (used_templates & current_templates) | current_templates 99 | 100 | template_win_rates = dict() 101 | for template in used_templates: 102 | wins = template_win_counts[template] 103 | loses = template_lose_counts[template] 104 | ties = template_tie_counts[template] 105 | bad_ties = template_bad_tie_counts[template] 106 | all_count = wins + loses + ties + bad_ties 107 | true_winrate = (wins + ties) / all_count if all_count != 0 else 0 108 | template_win_rates[template] = ( 109 | true_winrate, 110 | wins / (wins + loses) if wins + loses != 0 else 0, 111 | wins + loses, 112 | wins + loses + ties + bad_ties, 113 | ) 114 | 115 | for name, (tie_winrate, winrate, count, count_w_ties) in sorted( 116 | template_win_rates.items(), key=lambda x: x[1] 117 | ): 118 | print( 119 | f"{name: <30}{count_w_ties: <10}{count: <10}{tie_winrate:.2f} {winrate:.2f}" 120 | ) 121 | 122 | print() 123 | print("WIN examples:") 124 | for r in win_examples: 125 | if r.query is None: 126 | continue 127 | query = r.query.replace("\n", " ") 128 | meme = r.image_url.replace("\n", " ") 129 | print( 130 | f"TS: {r.created_at}, PROMPT: {query}, TEMPLATE: {r.template_id}, CAPTIONS: {r.captions}, URL: {r.public_url}" 131 | ) 132 | 133 | print() 134 | print("LOSE examples:") 135 | for r in lose_examples: 136 | if r.query is None: 137 | continue 138 | query = r.query.replace("\n", " ") 139 | meme = r.image_url.replace("\n", " ") 140 | print( 141 | f"TS: {r.created_at}, PROMPT: {query}, TEMPLATE: {r.template_id}, CAPTIONS: {r.captions}, URL: {r.public_url}" 142 | ) 143 | 144 | if False: 145 | for template, examples in template_win_examples.items(): 146 | print() 147 | print(template) 148 | for r in examples[-5:]: 149 | if not r.query: 150 | continue 151 | query = r.query.replace("\n", " ") 152 | captions = r.image_url.replace("\n", " ").split("/")[5:] 153 | meme = str([c.replace("_", " ") for c in captions]) 154 | print(f"PROMPT: {query}, MEME: {meme}, URL: {r.public_url}") 155 | 156 | db.close() 157 | 158 | 159 | if __name__ == "__main__": 160 | fire.Fire(get_stats) 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MEMETRON 3000 2 | 3 | An automatic Russian meme generator powered by vision-capable language models. MEMETRON 3000 uses AI to generate contextually appropriate memes based on text prompts and predefined meme templates. 4 | 5 | ## Features 6 | 7 | - **AI-Powered Generation**: Uses Google's Gemini 3 Pro Image via OpenRouter to generate memes 8 | - **Template-Based**: 20+ Russian meme templates with descriptions and examples 9 | - **Queue System**: Async job queue for handling multiple generation requests 10 | - **Gallery**: Browse all previously generated memes with pagination 11 | - **Thumbnail Generation**: Automatic thumbnail creation for fast loading 12 | - **REST API**: Full-featured API for programmatic access 13 | - **Web Interface**: Clean, modern web UI for generating and browsing memes 14 | 15 | ## Prerequisites 16 | 17 | - Python 3.11 or higher 18 | - [uv](https://docs.astral.sh/uv/) package manager (recommended) or pip 19 | - OpenRouter API key (for meme generation) 20 | 21 | ## Installation 22 | 23 | 1. Clone the repository: 24 | ```bash 25 | git clone https://github.com/IlyaGusev/memetron3000.git 26 | cd memetron3000 27 | ``` 28 | 29 | 2. Download meme template images: 30 | ```bash 31 | bash download.sh 32 | ``` 33 | 34 | 3. Install dependencies: 35 | ```bash 36 | # Using uv (recommended) 37 | uv sync 38 | 39 | # Or using pip 40 | pip3 install -e . 41 | ``` 42 | 43 | 4. Configure environment variables in `.env` file: 44 | - Set your `OPENROUTER_API_KEY` (get it from [OpenRouter](https://openrouter.ai/)) 45 | - Set `ENABLE_GENERATION=true` to enable meme generation 46 | 47 | ## Configuration 48 | 49 | Configure the application using environment variables: 50 | 51 | - `OPENROUTER_API_KEY` - **Required** for meme generation. Get your key from [OpenRouter](https://openrouter.ai/) 52 | - `ENABLE_GENERATION` - Enable/disable meme generation (default: `"false"`). Set to `"true"` to allow generation 53 | - `PROMPT_PATH` - Override default prompt template path (default: `genmeme/prompts/gen.jinja`) 54 | - `TEMPLATES_PATH` - Override default templates.json path (default: `templates.json`) 55 | 56 | ## Running the Server 57 | 58 | Start the server with: 59 | ```bash 60 | uv run -m genmeme.server 61 | ``` 62 | 63 | The server will start on `http://localhost:8090` by default. 64 | 65 | ### Command-line Options 66 | 67 | ```bash 68 | # Custom port and host 69 | uv run -m genmeme.server --port 8081 --host 0.0.0.0 70 | ``` 71 | 72 | ## API Documentation 73 | 74 | ### Generate Meme 75 | 76 | **POST** `/api/v1/predict` 77 | 78 | Submit a meme generation request. Returns a job ID for tracking progress. 79 | 80 | Request body: 81 | ```json 82 | { 83 | "prompt": "Your meme prompt in Russian", 84 | "selected_template_id": "bender" // Optional, random if not specified 85 | } 86 | ``` 87 | 88 | Response: 89 | ```json 90 | { 91 | "job_id": "uuid", 92 | "position": 1 93 | } 94 | ``` 95 | 96 | ### Check Job Status 97 | 98 | **GET** `/api/v1/job/{job_id}` 99 | 100 | Check the status of a meme generation job. 101 | 102 | Response: 103 | ```json 104 | { 105 | "job_id": "uuid", 106 | "status": "completed", // queued, processing, completed, or failed 107 | "position": 0, 108 | "created_at": "2025-12-01T12:00:00Z", 109 | "started_at": "2025-12-01T12:00:05Z", 110 | "completed_at": "2025-12-01T12:00:15Z", 111 | "result_url": "output/filename.jpg", 112 | "error": null 113 | } 114 | ``` 115 | 116 | ### Get Templates 117 | 118 | **GET** `/api/v1/templates` 119 | 120 | Get a list of all available meme templates. 121 | 122 | Response: 123 | ```json 124 | [ 125 | { 126 | "id": "bender", 127 | "name": "I'm Going to Build My Own Theme Park", 128 | "description": "Кадр из мультсериала 'Футурама'..." 129 | } 130 | ] 131 | ``` 132 | 133 | ### Gallery 134 | 135 | **GET** `/api/v1/gallery?page=1&page_size=24` 136 | 137 | Get paginated list of previously generated memes. 138 | 139 | Query parameters: 140 | - `page` - Page number (default: 1) 141 | - `page_size` - Items per page (default: 24, max: 100) 142 | 143 | Response: 144 | ```json 145 | { 146 | "memes": [ 147 | { 148 | "result_id": "uuid", 149 | "public_url": "output/uuid.jpg", 150 | "thumbnail_url": "output/thumbnails/uuid.jpg", 151 | "query": "Original prompt", 152 | "created_at": "2025-12-01T12:00:00Z", 153 | "template_ids": "bender,bilbo" 154 | } 155 | ], 156 | "total": 100, 157 | "page": 1, 158 | "page_size": 24, 159 | "total_pages": 5 160 | } 161 | ``` 162 | 163 | ### Queue Size 164 | 165 | **GET** `/api/v1/queue/size` 166 | 167 | Get the current queue size. 168 | 169 | ### Configuration 170 | 171 | **GET** `/api/v1/config` 172 | 173 | Get server configuration (e.g., whether generation is enabled). 174 | 175 | ### Health Check 176 | 177 | **GET** `/health` 178 | 179 | Health check endpoint for monitoring. 180 | 181 | ## Architecture 182 | 183 | ### Components 184 | 185 | - **server.py** - FastAPI web server with job queue management 186 | - **gen.py** - Core meme generation logic and template selection 187 | - **llm.py** - OpenRouter API integration for AI-powered image generation 188 | - **db.py** - SQLAlchemy models for storing meme metadata 189 | - **queue.py** - Async job queue system for handling generation requests 190 | - **thumbnails.py** - Image thumbnail generation using Pillow 191 | - **files.py** - Path constants and configuration 192 | 193 | ### Data Flow 194 | 195 | 1. User submits prompt via web UI or API 196 | 2. Server creates a job and adds it to the async queue 197 | 3. Queue worker picks up the job and starts processing 198 | 4. Random meme templates are selected (or specific template if requested) 199 | 5. Jinja2 prompt template is rendered with user query and template metadata 200 | 6. Prompt + template images sent to LLM via OpenRouter 201 | 7. LLM generates new meme image based on the prompt 202 | 8. Generated image is saved to `output/` directory 203 | 9. Thumbnail is created and saved to `output/thumbnails/` 204 | 10. Metadata is stored in SQLite database 205 | 11. Job status is updated with result URL 206 | 12. User can retrieve the generated meme 207 | 208 | ## Development 209 | 210 | ### Code Quality 211 | 212 | Format code: 213 | ```bash 214 | make black 215 | ``` 216 | 217 | Run all validations (formatting, linting, type checking): 218 | ```bash 219 | make validate 220 | ``` 221 | 222 | ### Manual Validation 223 | 224 | ```bash 225 | # Format with Black 226 | uv run black genmeme 227 | 228 | # Lint with flake8 229 | uv run flake8 genmeme 230 | 231 | # Type check with mypy (strict mode) 232 | uv run mypy genmeme --strict --explicit-package-bases 233 | ``` 234 | 235 | ### Code Style Guidelines 236 | 237 | - Use strict type checking with mypy 238 | - Follow Black formatting standards 239 | - Keep line length under 120 characters 240 | - Avoid obvious comments - code should be self-explanatory 241 | - Only comment complex logic that isn't immediately clear 242 | 243 | ## Project Structure 244 | 245 | ``` 246 | memetron3000/ 247 | ├── genmeme/ 248 | │ ├── __init__.py 249 | │ ├── server.py # FastAPI web server 250 | │ ├── gen.py # Meme generation logic 251 | │ ├── llm.py # LLM API integration 252 | │ ├── db.py # Database models 253 | │ ├── queue.py # Job queue manager 254 | │ ├── thumbnails.py # Thumbnail generation 255 | │ ├── files.py # Path constants 256 | │ └── prompts/ 257 | │ └── gen.jinja # Prompt template 258 | ├── static/ 259 | │ ├── index.html # Main UI 260 | │ └── gallery.html # Gallery UI 261 | ├── images/ # Meme template images 262 | ├── output/ # Generated memes 263 | │ └── thumbnails/ # Generated thumbnails 264 | ├── scripts/ # Utility scripts 265 | ├── templates.json # Meme template definitions 266 | ├── pyproject.toml # Project configuration 267 | ├── Makefile # Development tasks 268 | └── download.sh # Download template images 269 | 270 | ``` 271 | 272 | ## Technologies Used 273 | 274 | - **FastAPI** - Modern web framework for building APIs 275 | - **SQLAlchemy** - Database ORM for metadata storage 276 | - **OpenRouter** - API gateway for accessing LLMs 277 | - **Google Gemini 3 Pro Image** - Vision-capable LLM for meme generation 278 | - **Jinja2** - Template engine for prompt generation 279 | - **Pillow** - Image processing for thumbnails 280 | - **uvicorn** - ASGI server 281 | - **aiohttp** - Async HTTP client 282 | - **Pydantic** - Data validation 283 | 284 | ## License 285 | 286 | This project is provided as-is for educational and research purposes. 287 | 288 | ## Credits 289 | 290 | Created by [IlyaGusev](https://github.com/IlyaGusev) 291 | -------------------------------------------------------------------------------- /genmeme/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import traceback 4 | import asyncio 5 | import json 6 | import logging 7 | from typing import Optional, Dict, Any, List 8 | from contextlib import asynccontextmanager 9 | from pathlib import Path 10 | 11 | import fire # type: ignore 12 | import uvicorn 13 | from pydantic import BaseModel 14 | from fastapi import FastAPI, Request, HTTPException 15 | from fastapi.staticfiles import StaticFiles 16 | from fastapi.responses import HTMLResponse 17 | from dotenv import load_dotenv 18 | 19 | from genmeme.files import STORAGE_PATH, PROMPT_PATH, TEMPLATES_PATH 20 | from genmeme.gen import generate_meme 21 | from genmeme.db import ImageRecord, SessionLocal 22 | from genmeme.queue import QueueManager, JobStatus 23 | from genmeme.thumbnails import create_thumbnail 24 | 25 | 26 | logger = logging.getLogger("uvicorn") 27 | 28 | 29 | class EndpointFilter(logging.Filter): 30 | def filter(self, record: logging.LogRecord) -> bool: 31 | # Filter out queue size and job status polling endpoints 32 | message = record.getMessage() 33 | return "/api/v1/queue/size" not in message and "/api/v1/job/" not in message 34 | 35 | 36 | NUM_RETRIES = 5 37 | QUEUE_MANAGER = QueueManager() 38 | 39 | 40 | class PredictRequest(BaseModel): 41 | prompt: str 42 | selected_template_id: Optional[str] = None 43 | 44 | 45 | class PredictResponse(BaseModel): 46 | job_id: str 47 | position: int 48 | 49 | 50 | class QueueSizeResponse(BaseModel): 51 | size: int 52 | 53 | 54 | class JobStatusResponse(BaseModel): 55 | job_id: str 56 | status: JobStatus 57 | position: int 58 | created_at: datetime.datetime 59 | selected_template_id: Optional[str] = None 60 | started_at: Optional[datetime.datetime] = None 61 | completed_at: Optional[datetime.datetime] = None 62 | result_url: Optional[str] = None 63 | error: Optional[str] = None 64 | 65 | 66 | class TemplateInfo(BaseModel): 67 | id: str 68 | name: str 69 | description: str 70 | 71 | 72 | class MemeInfo(BaseModel): 73 | result_id: str 74 | public_url: str 75 | thumbnail_url: Optional[str] = None 76 | query: Optional[str] = None 77 | created_at: Optional[datetime.datetime] = None 78 | template_ids: Optional[str] = None 79 | 80 | 81 | class GalleryResponse(BaseModel): 82 | memes: List[MemeInfo] 83 | total: int 84 | page: int 85 | page_size: int 86 | total_pages: int 87 | 88 | 89 | class ConfigResponse(BaseModel): 90 | generation_enabled: bool 91 | 92 | 93 | async def process_queue_worker() -> None: 94 | while True: 95 | job = await QUEUE_MANAGER.queue.get() 96 | try: 97 | QUEUE_MANAGER.update_job_status(job.job_id, JobStatus.PROCESSING) 98 | QUEUE_MANAGER.is_processing = True 99 | 100 | generate_prompt_path = str(PROMPT_PATH) 101 | env_prompt_path = os.getenv("PROMPT_PATH") 102 | if env_prompt_path: 103 | generate_prompt_path = env_prompt_path 104 | 105 | templates_path = str(TEMPLATES_PATH) 106 | env_templates_path = os.getenv("TEMPLATES_PATH") 107 | if env_templates_path: 108 | templates_path = env_templates_path 109 | 110 | for attempt in range(NUM_RETRIES): 111 | try: 112 | response = await generate_meme( 113 | job.prompt, 114 | generate_prompt_path=generate_prompt_path, 115 | templates_path=templates_path, 116 | selected_template_id=job.selected_template_id, 117 | ) 118 | break 119 | except Exception: 120 | if attempt == NUM_RETRIES - 1: 121 | raise 122 | traceback.print_exc() 123 | 124 | public_url = f"output/{response.file_name}" 125 | 126 | # Generate thumbnail 127 | image_path = Path(STORAGE_PATH) / response.file_name 128 | thumbnail_dir = Path(STORAGE_PATH) / "thumbnails" 129 | thumbnail_dir.mkdir(exist_ok=True) 130 | thumbnail_name = response.file_name 131 | thumbnail_path = thumbnail_dir / thumbnail_name 132 | thumbnail_url = f"output/thumbnails/{thumbnail_name}" 133 | 134 | try: 135 | create_thumbnail(image_path, thumbnail_path) 136 | except Exception as e: 137 | logger.error(f"Failed to create thumbnail: {e}") 138 | thumbnail_url = public_url 139 | 140 | logger.info( 141 | f'OUTPUT job_id="{job.job_id}" file="{response.file_name}" templates="{",".join(response.template_ids)}"' 142 | ) 143 | 144 | db = SessionLocal() 145 | db_record = ImageRecord( 146 | result_id=response.file_name.split(".")[0], 147 | public_url=public_url, 148 | thumbnail_url=thumbnail_url, 149 | query=job.prompt, 150 | created_at=datetime.datetime.now(datetime.UTC), 151 | template_ids=",".join(response.template_ids), 152 | ) 153 | db.add(db_record) 154 | db.commit() 155 | db.close() 156 | 157 | QUEUE_MANAGER.update_job_status( 158 | job.job_id, 159 | JobStatus.COMPLETED, 160 | result_url=public_url, 161 | ) 162 | 163 | except Exception as e: 164 | error_msg = str(e) 165 | traceback.print_exc() 166 | QUEUE_MANAGER.update_job_status( 167 | job.job_id, JobStatus.FAILED, error=error_msg 168 | ) 169 | finally: 170 | QUEUE_MANAGER.is_processing = False 171 | QUEUE_MANAGER.queue.task_done() 172 | 173 | 174 | @asynccontextmanager 175 | async def lifespan(app: FastAPI): # type: ignore 176 | task = asyncio.create_task(process_queue_worker()) 177 | yield 178 | task.cancel() 179 | try: 180 | await task 181 | except asyncio.CancelledError: 182 | pass 183 | 184 | 185 | APP = FastAPI(lifespan=lifespan) 186 | 187 | 188 | def get_base_url(request: Request) -> str: 189 | forwarded_proto = request.headers.get("X-Forwarded-Proto", "http") 190 | forwarded_host = request.headers.get( 191 | "X-Forwarded-Host", request.headers.get("Host", "localhost") 192 | ) 193 | return f"{forwarded_proto}://{forwarded_host}" 194 | 195 | 196 | @APP.post("/api/v1/predict", response_model=PredictResponse) 197 | async def predict(request: PredictRequest, req: Request) -> PredictResponse: 198 | generation_enabled = os.getenv("ENABLE_GENERATION", "false").lower() == "true" 199 | if not generation_enabled: 200 | raise HTTPException( 201 | status_code=503, detail="Meme generation is currently disabled" 202 | ) 203 | job = QUEUE_MANAGER.create_job(request.prompt, request.selected_template_id) 204 | position = await QUEUE_MANAGER.enqueue(job) 205 | logger.info( 206 | f'QUERY job_id="{job.job_id}" template="{request.selected_template_id or "random"}" prompt="{request.prompt[:100]}"' 207 | ) 208 | return PredictResponse(job_id=job.job_id, position=position) 209 | 210 | 211 | @APP.get("/api/v1/queue/size", response_model=QueueSizeResponse) 212 | async def get_queue_size() -> QueueSizeResponse: 213 | size = QUEUE_MANAGER.get_queue_size() 214 | return QueueSizeResponse(size=size) 215 | 216 | 217 | @APP.get("/api/v1/job/{job_id}", response_model=JobStatusResponse) 218 | async def get_job_status(job_id: str) -> JobStatusResponse: 219 | job = QUEUE_MANAGER.get_job(job_id) 220 | if not job: 221 | raise HTTPException(status_code=404, detail="Job not found") 222 | 223 | return JobStatusResponse( 224 | job_id=job.job_id, 225 | status=job.status, 226 | position=job.position, 227 | created_at=job.created_at, 228 | selected_template_id=job.selected_template_id, 229 | started_at=job.started_at, 230 | completed_at=job.completed_at, 231 | result_url=job.result_url, 232 | error=job.error, 233 | ) 234 | 235 | 236 | @APP.get("/api/v1/templates", response_model=List[TemplateInfo]) 237 | async def get_templates() -> List[TemplateInfo]: 238 | templates_path = str(TEMPLATES_PATH) 239 | env_templates_path = os.getenv("TEMPLATES_PATH") 240 | if env_templates_path: 241 | templates_path = env_templates_path 242 | 243 | templates_data = json.loads(Path(templates_path).read_text()) 244 | # Filter to only include image templates, not video 245 | image_templates = [t for t in templates_data if t.get("type", "image") == "image"] 246 | return [ 247 | TemplateInfo( 248 | id=t["id"], 249 | name=t["name"], 250 | description=t.get("description", ""), 251 | ) 252 | for t in image_templates 253 | ] 254 | 255 | 256 | @APP.get("/api/v1/gallery", response_model=GalleryResponse) 257 | async def get_gallery(page: int = 1, page_size: int = 24) -> GalleryResponse: 258 | db = SessionLocal() 259 | try: 260 | # Ensure valid pagination parameters 261 | page = max(1, page) 262 | page_size = max(1, min(100, page_size)) # Max 100 items per page 263 | 264 | # Get total count 265 | total = db.query(ImageRecord).count() 266 | 267 | # Calculate total pages 268 | total_pages = (total + page_size - 1) // page_size if total > 0 else 1 269 | 270 | # Get paginated records 271 | offset = (page - 1) * page_size 272 | records = ( 273 | db.query(ImageRecord) 274 | .order_by(ImageRecord.created_at.asc()) 275 | .limit(page_size) 276 | .offset(offset) 277 | .all() 278 | ) 279 | 280 | memes = [ 281 | MemeInfo( 282 | result_id=r.result_id, 283 | public_url=r.public_url, 284 | thumbnail_url=r.thumbnail_url or r.public_url, 285 | query=r.query, 286 | created_at=r.created_at, 287 | template_ids=r.template_ids, 288 | ) 289 | for r in records 290 | ] 291 | 292 | return GalleryResponse( 293 | memes=memes, 294 | total=total, 295 | page=page, 296 | page_size=page_size, 297 | total_pages=total_pages, 298 | ) 299 | finally: 300 | db.close() 301 | 302 | 303 | @APP.get("/", response_class=HTMLResponse) 304 | async def root() -> str: 305 | static_dir = Path(__file__).parent.parent / "static" 306 | index_path = static_dir / "index.html" 307 | if index_path.exists(): 308 | return index_path.read_text() 309 | return "

MEMETRON 3000

Frontend not found

" 310 | 311 | 312 | @APP.get("/gallery", response_class=HTMLResponse) 313 | async def gallery() -> str: 314 | static_dir = Path(__file__).parent.parent / "static" 315 | gallery_path = static_dir / "gallery.html" 316 | if gallery_path.exists(): 317 | return gallery_path.read_text() 318 | return "

Gallery

Gallery page not found

" 319 | 320 | 321 | @APP.get("/api/v1/config", response_model=ConfigResponse) 322 | async def get_config() -> ConfigResponse: 323 | generation_enabled = os.getenv("ENABLE_GENERATION", "false").lower() == "true" 324 | return ConfigResponse(generation_enabled=generation_enabled) 325 | 326 | 327 | @APP.get("/health") 328 | async def health_check() -> Dict[str, Any]: 329 | return {"status": "healthy", "timestamp": datetime.datetime.utcnow().isoformat()} 330 | 331 | 332 | APP.mount("/output", StaticFiles(directory=STORAGE_PATH), name="output") 333 | APP.mount( 334 | "/static", 335 | StaticFiles(directory=str(Path(__file__).parent.parent / "static")), 336 | name="static", 337 | ) 338 | 339 | 340 | def main(host: str = "0.0.0.0", port: int = 8090) -> None: 341 | # Add filter to uvicorn access logger to exclude polling endpoints 342 | logging.getLogger("uvicorn.access").addFilter(EndpointFilter()) 343 | uvicorn.run(APP, host=host, port=port) 344 | 345 | 346 | if __name__ == "__main__": 347 | load_dotenv() 348 | fire.Fire(main) 349 | -------------------------------------------------------------------------------- /static/gallery.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Gallery - MEMETRON 3000 7 | 8 | 241 | 242 | 243 |
244 |
245 |

MEMETRON 3000

246 |

Gallery

247 |
248 | 249 | 252 | 253 |
Loading gallery...
254 | 255 | 256 | 260 |
261 | 262 | 265 | 266 | 272 | 273 | 382 | 383 | 384 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MEMETRON 3000 7 | 8 | 248 | 249 | 250 |
251 |
252 |

MEMETRON 3000

253 |
254 | 255 | 258 | 259 |
260 | Queue size: 0 261 |
262 | 263 | 272 | 273 |
274 |

Generate Meme

275 |
276 |
277 | 278 | 279 |
280 | 281 |
282 | 283 | 286 |
287 | 288 | 289 |
290 |
291 | 292 |
293 |

Generation Status

294 |
295 |
296 |
297 |
298 | 299 | 302 | 303 | 514 | 515 | 516 | -------------------------------------------------------------------------------- /templates.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "bender", 4 | "name": "I'm Going to Build My Own Theme Park", 5 | "example": { 6 | "text": [ 7 | "i'm going to build my own theme park", 8 | "with blackjack and hookers" 9 | ] 10 | }, 11 | "description": "Кадр из мультсериала 'Футурама', где робот Бендер говорит: 'Я построю свой луна-парк с блэкджеком и шлюхами!'. Используется как шаблон для шуток о создании альтернативной версии чего-либо из-за недовольства оригиналом.", 12 | "query_examples": [{ 13 | "query": "Не костыли, а продвинутые архитектурыне решения", 14 | "captions": [ 15 | "Построю свою архитектуру", 16 | "С микросервисами и паттернами" 17 | ] 18 | }, { 19 | "query": "Главный раввин России назвал своего любимого рэпера — это Джиган.", 20 | "captions": [ 21 | "Я создам свой рэп-фестиваль", 22 | "С раввинами и хупой" 23 | ] 24 | }] 25 | }, 26 | { 27 | "id": "bilbo", 28 | "name": "Why Shouldn't I Keep It", 29 | "example": { 30 | "text": [ 31 | "After all... why not?", 32 | "Why shouldn't I post cringe?" 33 | ] 34 | }, 35 | "description": "Два кадра из фильма 'Властелин колец', где Бильбо Бэггинс держит кольцо и с подозрительным/жадным выражением лица надевает кольцо Всевластия. Используется для шуток о нежелании чем-то делиться или расставаться с чем-то.", 36 | "query_examples": [{ 37 | "query": "Мемы по Сумеркам", 38 | "captions": [ 39 | "Узнала, что он вампир...", 40 | "Почему бы и не начать с ним встречаться?" 41 | ] 42 | }] 43 | }, 44 | { 45 | "id": "chair", 46 | "name": "American Chopper Argument", 47 | "example": { 48 | "text": [ 49 | "Let's expand safety nets", 50 | "Socialism never works!", 51 | "Scandinavia is socialist and they're doing great!", 52 | "They're not socialist. They're capitalist with strong welfare policies!", 53 | "Then let's adopt those!", 54 | "No that's socialism!!" 55 | ] 56 | }, 57 | "description": "Мем состоит из 6 панелей со скриншотами из реалити-шоу American Chopper, где отец и сын Тетулы эмоционально спорят. Формат используется для изображения различных споров, где каждая панель содержит новый аргумент участников дискуссии. Спорщика два, их реплики чередуются.", 58 | "query_examples": [{ 59 | "query": "Муж не понимает, как я, русский человек, могу есть авокадо", 60 | "captions": [ 61 | "Авокадо - не русская еда!", 62 | "А картошка из Южной Америки!", 63 | "Но картошка уже стала нашей!", 64 | "Как и помидоры с огурцами из Азии", 65 | "Ну это другое!", 66 | "Да ты просто не пробовал гуакамоле" 67 | ] 68 | }] 69 | }, 70 | { 71 | "id": "db", 72 | "name": "Distracted Boyfriend", 73 | "example": { 74 | "text": [ 75 | "Новая игра по скидке", 76 | "Я", 77 | "Недоигранные 50 игр в Steam" 78 | ] 79 | }, 80 | "description": "Мем, основанный на стоковой фотографии, сделанной испанским фотографом Антонио Гильемом. На фото изображены три человека: парень, идущий за руку с девушкой, но демонстративно оборачивающийся, чтобы посмотреть на другую девушку; его девушка, с возмущением смотрящая на его поведение; девушка в красном, проходящая мимо, на которую засмотрелся парень. Мем используется для иллюстрации различных ситуаций, связанных с выбором, изменой или переключением внимания. Первая подпись на девушке в красном, вторая подпись на парне, третья подпись на недовольной девушке.", 81 | "query_examples": [{ 82 | "query": "Кеша сказал, что пишет курсовую, а сам играет в роблокс", 83 | "captions": [ 84 | "Роблокс", 85 | "Кеша", 86 | "Недописанная курсовая" 87 | ] 88 | }, { 89 | "query": "игры в стиме", 90 | "captions": [ 91 | "Новая игра по скидке", 92 | "Я", 93 | "Недоигранные 50 игр в Steam" 94 | ] 95 | }] 96 | }, 97 | { 98 | "id": "drake", 99 | "name": "Drakeposting", 100 | "example": { 101 | "text": [ 102 | "писать сочинение по книге", 103 | "писать сочинение по краткому содержанию" 104 | ] 105 | }, 106 | "description": "Верхняя панель: Дрейк отворачивается и поднимает руку в отвергающем жесте. Нижняя панель: Дрейк улыбается и указывает на что-то с одобрением", 107 | "query_examples": [{ 108 | "query": "Минфин США ежегодно переводит $100 млрд неустановленным лицам, заявил Илон Маск", 109 | "captions": [ 110 | "Проверять личность получателей пособий", 111 | "Раздавать деньги любому, кто скажет 'дайте денег'" 112 | ] 113 | }, { 114 | "query": "урок литературы", 115 | "captions": [ 116 | "писать сочинение по книге", 117 | "писать сочинение по краткому содержанию" 118 | ] 119 | }] 120 | }, 121 | { 122 | "id": "ds", 123 | "name": "Daily Struggle", 124 | "example": { 125 | "text": [ 126 | "Досмотреть сериал за ночь", 127 | "Выспаться перед важной встречей", 128 | "3 часа ночи" 129 | ] 130 | }, 131 | "description": "Сверху мема панель с двумя красными кнопками. Нижняя панель показывает персонажа в красной одежде, который нервничает и вытирает пот со лба, испытывая стресс от необходимости выбора. Первые две подписи для кнопок, последняя для дополнительного описания ситуации (необязательно).", 132 | "query_examples": [{ 133 | "query": "Кирилл очень душный", 134 | "captions": [ 135 | "Промолчать и сохранить друзей", 136 | "Объяснить почему их мнение неправильное с 40 пруфами", 137 | "Кирилл на тусовке" 138 | ] 139 | }, { 140 | "query": "я люблю сериалы", 141 | "captions": [ 142 | "Досмотреть сериал за ночь", 143 | "Выспаться перед важной встречей", 144 | "3 часа ночи" 145 | ] 146 | }] 147 | }, 148 | { 149 | "id": "elmo", 150 | "name": "Elmo Choosing Cocaine", 151 | "example": { 152 | "text": [ 153 | "Historical Accuracy", 154 | "History Channel", 155 | "Aliens", 156 | "Historical Accuracy" 157 | ] 158 | }, 159 | "description": "Кадры с персонажем 'Улицы Сезам'. Первый кадр: Элмо сидит за столом, на котором лежат фрукты и белый порошок (предположительно мука/сахар для комического эффекта). Второй кадр: Элмо лежит лицом в этом порошке, таким образом выбирая его. Мем используется для шуток о зависимости от чего-либо или чрезмерном увлечении чем-то. Важно отметить, что это комедийный мем с детской игрушкой, и белый порошок в оригинальном видео - это безобидное вещество вроде муки или сахара, используемое для создания комического эффекта. Первая и последняя подписи - это фрукты, они должны совпадать. Вторая подпись - это Элмо. Третья подпись - порошок.", 160 | "query_examples": [{ 161 | "query": "Тиша это как выиграть в лотерею, он может неделю игнорить а может вступить в 3-х часовую переписку", 162 | "captions": [ 163 | "Нормальное общение", 164 | "Тиша", 165 | "Трёхчасовая переписка в 3 часа ночи", 166 | "Нормальное общение" 167 | ] 168 | }, { 169 | "query": "Наша власть только над своими мыслями, словами и действиями. Это наша ответственность. Остальное — нет.", 170 | "captions": [ 171 | "Нормальная забота о себе", 172 | "Я", 173 | "Взять ответственность за всё и всех", 174 | "Нормальная забота о себе" 175 | ] 176 | }] 177 | }, 178 | { 179 | "id": "gb", 180 | "name": "Galaxy Brain", 181 | "example": { 182 | "text": [ 183 | "Who", 184 | "Whom", 185 | "Whom'st", 186 | "Whomst'd" 187 | ] 188 | }, 189 | "description": "Четырёхпанельный мем, показывающий прогрессию. Начинается с обычного портрета человеческой головы, далее идут изображения светящегося мозга, становящегося всё ярче, последние панели показывают космический мозг. Юмор строится на том, что самые глупые идеи представлены как самые 'просветлённые'.", 190 | "query_examples": [{ 191 | "query": "Синоптики обещают заморозки садоводы бегут укрывать посадки", 192 | "captions": [ 193 | "Укрыть посадки агроволокном", 194 | "Обогреть тепловой пушкой", 195 | "Перенести все растения домой", 196 | "Переехать с огородом в Сочи" 197 | ] 198 | }, { 199 | "query": "Как заработать много денег", 200 | "captions": [ 201 | "Устроиться на работу", 202 | "Открыть свой бизнес", 203 | "Продать почку", 204 | "Продать обе почки" 205 | ] 206 | }] 207 | }, 208 | { 209 | "id": "grave", 210 | "name": "Grant Gustin Next To Oliver Queen's Grave", 211 | "example": { 212 | "text": [ 213 | "Jesus", 214 | "", 215 | "Jesus 3 days later" 216 | ] 217 | }, 218 | "description": "Фото со съёмок сериала, где актёр Грант Гастин стоит, улыбаясь и опираясь на надгробие персонажа Оливера Куина из сериала 'Стрела'. Из-за неудачного контекста (улыбка у могилы) фото стало мемом. Первая подпись на надгробии, вторая подпись на самой могиле (не обязательна, почти никогда не используется), третья подпись на актёре.", 219 | "query_examples": [{ 220 | "query": "Игорь, Никита и Диана четвертый час разбираются в правилах игры Немезида и не могут начать играть. В это время Артем в спортзале рвет мениски", 221 | "captions": [ 222 | "Мениски Артема, 1995-2025", 223 | "", 224 | "Остальные, которые все еще читают правила Немезиды" 225 | ] 226 | }, { 227 | "query": "библия", 228 | "captions": [ 229 | "Иисус", 230 | "", 231 | "Иисус 3 дня спустя" 232 | ] 233 | }] 234 | }, 235 | { 236 | "id": "gru", 237 | "name": "Gru's Plan", 238 | "example": { 239 | "text": [ 240 | "Learn how to make memes.", 241 | "Make a meme.", 242 | "No one likes it.", 243 | "No one likes it." 244 | ] 245 | }, 246 | "description": "Четырёхпанельный мем, где главный герой мультфильма «Гадкий я» Грю использует презентационную доску. В третьей панели появляется неожиданный слайд презентации, за которым следует панель, где Грю в замешательстве оглядывается на доску. Содержание третьей и четвёртой подпсией должно совпадать. Шутка в обмане ожиданий.", 247 | "query_examples": [{ 248 | "query": "Стань доброй, Лиза", 249 | "captions": [ 250 | "Поговорить с Лизой по-доброму", 251 | "Попросить её быть добре", 252 | "Лиза стала ещё злее", 253 | "Лиза стала ещё злее" 254 | ] 255 | }] 256 | }, 257 | { 258 | "id": "perfection", 259 | "name": "Perfection", 260 | "example": { 261 | "text": [ 262 | "Sebastian Shaw", 263 | "I prefer the real Darth Vader.", 264 | "Hayden Christensen", 265 | "I said, the real Darth Vader.", 266 | "Jake Lloyd", 267 | "Perfection." 268 | ] 269 | }, 270 | "description": "Комикс “Превосходно” с Фассбендером в роли Магнето состоит из 6 квадратов и всегда строится по одинаковой схеме. На первой полосе какая-то картинка, справа от нее – Фассбендер, который говорит “Я хочу увидеть настоящего/настоящую”. На второй полосе – похожая картинка, а Фассбендер говорит “Я сказал настоящую!”. Наконец, на третьей полосе появляется другая похожая картинка, и удовлетворенный Фассбендер произносит “Превосходно!”.", 271 | "query_examples": [{ 272 | "query": "сделал аватарку в детстве и считал ее крутой, поставил ватермарку 'анти хуй'", 273 | "captions": [ 274 | "Копирайт", 275 | "Покажи настоящую защиту аватарки", 276 | "Вотермарка", 277 | "Я сказал, настоящую защиту!", 278 | "'анти хуй'", 279 | "Превосходно" 280 | ] 281 | }, { 282 | "query": "Харисенок везде хвастается своим эпическим волком", 283 | "captions": [ 284 | "Волк из 'Игры престолов'", 285 | "Покажи мне настоящего волка", 286 | "Волк из National Geographic", 287 | "Я сказал, настоящего волка!", 288 | "Волк Харисенка", 289 | "Превосходно!" 290 | ] 291 | }] 292 | }, 293 | { 294 | "id": "pooh", 295 | "name": "Tuxedo Winnie the Pooh", 296 | "example": { 297 | "text": [ 298 | "Я работаю в макдаке", 299 | "Я работаю с частичной занятостью в индустрии фастфуда" 300 | ] 301 | }, 302 | "description": "Мем, представляющий два изображения Винни-Пуха: простое, классическое изображение медведя сверху и Винни-Пух в смокинге с надменным/аристократическим выражением лица снизу. Мем используется для сравнения обычной и 'претенциозной' версии одного и того же понятия, где второй вариант представляет более сложную, вычурную или 'интеллектуальную' версию первого.", 303 | "query_examples": [{ 304 | "query": "«Глобальная цель — оптимизировать расходную часть» — такой вектор задают селлеры.", 305 | "captions": [ 306 | "Продавать больше, тратить меньше", 307 | "Реализовать стратегию опережающего роста категории при оптимизации операционных издержек и повышении unit-экономики" 308 | ] 309 | }, { 310 | "query": "костыли в коде", 311 | "captions": [ 312 | "Костыли в коде", 313 | "Временные архитектурные решения для обеспечения обратной совместимости" 314 | ] 315 | }] 316 | }, 317 | { 318 | "id": "ptj", 319 | "name": "Phoebe Teaching Joey", 320 | "example": { 321 | "text": [ 322 | "Мы", 323 | "Мы", 324 | "держимся", 325 | "держимся", 326 | "вместе", 327 | "вместе", 328 | "Мы держимся вместе!", 329 | "Мы должны разделиться!" 330 | ] 331 | }, 332 | "description": "Мем из Друзей. В оригинале полиглот Фиби пытается научить Джоуи французскому языку. Проведя несколько пробных занятий Буффе поняла, что ученик из Триббиани такой себе, поэтому предложила ему изучать отдельные фразы по слогам. Но в конце Джоуи выдаёт совсем другую фразу.", 333 | "query_examples": [{ 334 | "query": "фильм ужасов", 335 | "captions": [ 336 | "Мы", 337 | "Мы", 338 | "держимся", 339 | "держимся", 340 | "вместе", 341 | "вместе", 342 | "Мы держимся вместе!", 343 | "Мы должны разделиться!" 344 | ] 345 | }] 346 | }, 347 | { 348 | "id": "reveal", 349 | "name": "Scooby Doo Reveal", 350 | "example": { 351 | "text": [ 352 | "Полезный туториал на YouTube", 353 | "Посмотрим, кто ты на самом деле...", 354 | "40 минут воды", 355 | "Я так и знал!" 356 | ] 357 | }, 358 | "description": "Мем основан на классическом моменте из мультсериала 'Скуби-Ду', где команда срывает маску с злодея, чтобы раскрыть его истинную личность.", 359 | "query_examples": [{ 360 | "query": "Садоводы лучшие метеорологи", 361 | "captions": [ 362 | "Прогноз погоды от метеоцентра", 363 | "Посмотрим, кто ты на самом деле...", 364 | "Бабушка, которая по муравьям и одуванчикам всё угадала", 365 | "Я так и знал!" 366 | ] 367 | }, { 368 | "query": "I can play the doctor", 369 | "captions": [ 370 | "'Я врач, могу помочь!'", 371 | "Так-так, посмотрим кто тут у нас...", 372 | "15 сезонов 'Анатомии Грей'", 373 | "Я так и знал!" 374 | ] 375 | }] 376 | }, 377 | { 378 | "id": "twodoges", 379 | "name": "Swole Doge vs. Cheems", 380 | "example": { 381 | "text": [ 382 | "Priest 1450", 383 | "I burnt 15 witches", 384 | "Priest 2020", 385 | "I'm afraid of gays" 386 | ] 387 | }, 388 | "description": "Cравнение двух собак породы сиба-ину. Доге - сильный, уверенный пёс с характерной усмешкой/ухмылкой. Чимс - неуклюжий пёс с опухшими щеками и растерянным выражением морды. Мем используется для сравнения: прошлого и настоящего, идеала и реальности, профессионала и новичка. 1 и 3 подпись для самих собак, 2 и 4 для их высказываний. Сначала идут подписи Доге, затем Чимса.", 389 | "query_examples": [{ 390 | "query": "Минтруд подтвердил дефицит разработчиков ПО в России", 391 | "captions": [ 392 | "HR в 2020", 393 | "Ой, да этих программистов как собак нерезаных", 394 | "HR в 2024", 395 | "Умоляю, хотя бы джуна, я на колени встану!" 396 | ] 397 | }, { 398 | "query": "Сдаётся квартира", 399 | "captions": [ 400 | "Квартира на фото", 401 | "180 м², евроремонт, вид на парк", 402 | "Квартира при просмотре", 403 | "18 м², европлесень, вид на помойку" 404 | ] 405 | }] 406 | }, 407 | { 408 | "id": "vince", 409 | "name": "Vince McMahon Reaction", 410 | "example": { 411 | "text": [ 412 | "Сдал экзамен", 413 | "Сдал на отлично", 414 | "Единственный в группе" 415 | ] 416 | }, 417 | "description": "Мем состоит из 3 последовательных изображений, где Винс Макмэн (владелец WWE) проявляет всё более сильное возбуждение и восторг: от легкой заинтересованности до полного экстаза.", 418 | "query_examples": [{ 419 | "query": "На рынке среди свечников жесткая нехватка качественного воска", 420 | "captions": [ 421 | "Узнал про дефицит воска", 422 | "Вспомнил про пасеку дяди", 423 | "Скупил все ульи в районе" 424 | ] 425 | }, { 426 | "query": "экзамен", 427 | "captions": [ 428 | "Сдал экзамен", 429 | "Сдал на отлично", 430 | "Единственный в группе" 431 | ] 432 | }] 433 | }, 434 | { 435 | "id": "woman-cat", 436 | "name": "Woman Yelling at a Cat", 437 | "example": { 438 | "text": [ 439 | "Mom telling me how useless I am", 440 | "12 year old me playing Minecraft" 441 | ] 442 | }, 443 | "description": "Слева: кадр из реалити-шоу, где Тейлор Армстронг эмоционально кричит и указывает пальцем, а её подруга пытается её успокоить. Справа: фото белого кота по кличке Смудж (Smudge), который сидит за столом с тарелкой с салатом и выглядит растерянным/недовольным. Мем стал вирусным благодаря забавному контрасту между агрессивным криком женщины и озадаченным/равнодушным выражением морды кота. Обычно используется для создания диалогов, где первый человек что-то эмоционально доказывает, а кот невозмутимо отвечает или просто недоумевает.", 444 | "query_examples": [{ 445 | "query": "ежыпалка", 446 | "captions": [ 447 | "ЁЖИК ПИШЕТСЯ ЧЕРЕЗ Ё И И!", 448 | "ежыпалка" 449 | ] 450 | }, { 451 | "query": "Лена, Катя и Юля скучают по коллеге Ире", 452 | "captions": [ 453 | "ИРАААА, БЕЗ ТЕБЯ ТУТ ТАК СКУЧНО, ВЕРНИСЬ!!!", 454 | "Ира в новом офисе с зарплатой в 2 раза больше" 455 | ] 456 | }] 457 | }, 458 | { 459 | "id": "chill", 460 | "name": "Just a Chill Guy", 461 | "example": { 462 | "text": [ 463 | "Me when I get a low SAT score but I remember", 464 | "I'm a chill guy who low-key doesn't GAF" 465 | ] 466 | }, 467 | "description": "Мем о забавном антропоморфном псе, нарисованный в мультяшном стиле. Герой одет в серый лонгслив, джинсы с подворотами и красные кеды. Главная фишка персонажа — спокойный взгляд и легкая полуулыбка. Чилловый парень выглядит максимально расслабленно и умиротворенно — именно эта эмоция и превратила забавную картинку в мем.", 468 | "query_examples": [{ 469 | "query": "Убийства дельфинов в позитивном ключе", 470 | "captions": [ 471 | "Когда случайно загарпунил дельфина, но вспомнил", 472 | "Что они насилуют рыб и заслужили" 473 | ] 474 | }, { 475 | "query": "эказмены", 476 | "captions": [ 477 | "Получил 50 баллов на ЕГЭ, но вспомнил", 478 | "Что я просто чиловый парень и мне пофиг" 479 | ] 480 | }] 481 | }, 482 | { 483 | "id": "dramatic-dmitry", 484 | "name": "Драматичный Дмитрий", 485 | "type": "image", 486 | "example": { 487 | "text": [ 488 | "The most painful thing when I go to school...", 489 | "Forgetting to bring my headphones" 490 | ] 491 | }, 492 | "description": "Мем включает несколько изображений, где мужчина выражает грусть на пляже. На обложке он с глубокой задумчивостью пропускает песок сквозь пальцы; сидит на коленях с раскинутыми руками, глядя в небо; и принимает другие позы, демонстрирующие отчаяние. Можно использовать в разных грустных ситуациях.", 493 | "query_examples": [{ 494 | "query": "детские игрушки", 495 | "captions": [ 496 | "Решил купить машину", 497 | "хватило денег только на инерционную игрушку" 498 | ] 499 | }, { 500 | "query": "жизненная ситуация", 501 | "captions": [ 502 | "Когда пошёл в школу", 503 | "но забыл наушники дома" 504 | ] 505 | }] 506 | }, 507 | { 508 | "id": "astrologers", 509 | "name": "Астрологи объявили неделю X", 510 | "example": { 511 | "text": [ 512 | "Астрологи объявили неделю Ди Каприо", 513 | "Количество шуток про Оскар увеличилось втрое" 514 | ] 515 | }, 516 | "description": "В Героях 3 в начале каждой недели сообщается о том, что астрологи объявили неделю X, где X может быть одним из монстров, просто ничего не значащим названием или чумой. Если неделя монстра, то есть шанс, что монстры появляются на всей карте в виде нейтральных, а их прирост в замках удваивается. С астрологией это никак не связано, но для юмора и отсылки на игру говорят, что объявили неделю X, и теперь всем интересно X. На деле X просто является очередной хайповой темой, о которой все говорят.", 517 | "query_examples": [{ 518 | "query": "Почему Лизе так нравится этот моделизм что даже ей нравится нюхать клей из звезды", 519 | "captions": [ 520 | "Астрологи объявили неделю набора 'Звезда'", 521 | "Количество нюхающих клей моделистов увеличилось втрое" 522 | ] 523 | }, { 524 | "query": "актёры", 525 | "captions": [ 526 | "Астрологи объявили неделю Ди Каприо", 527 | "Количество шуток про Оскар увеличилось втрое" 528 | ] 529 | }] 530 | }, 531 | { 532 | "id": "handshake", 533 | "name": "Epic Handshake", 534 | "example": { 535 | "text": [ 536 | "me making these meme", 537 | "you seeing this meme", 538 | "us wasting time" 539 | ] 540 | }, 541 | "description": "Мем-макрос с рукопожатием мускулистых чёрной и белой рук. Мем используется для демонстрации неожиданного единогласия обычно непохожих личностей или групп в каком-то определённом вопросе – часто на почве общей неприязни к третьей группе. Третья подпись - это обычно какое-то действие.", 542 | "query_examples": [{ 543 | "query": "Хотел стать сеньором, теперь хочу стать нейросетью", 544 | "captions": [ 545 | "Сеньоры, не умеющие объяснить как они решают задачи", 546 | "Нейросети, не умеющие объяснить как они решают задачи", 547 | "Гениальные решения" 548 | ] 549 | }] 550 | }, 551 | { 552 | "id": "oracle", 553 | "name": "Alladin Oracle", 554 | "example": { 555 | "text": [ 556 | "Если человек родился глухим, на каком языке он думает?" 557 | ] 558 | }, 559 | "description": "Мем построен на комичном противоречии. В первой части мистический персонаж (оракул) заявляет 'Я оракул, я отвечу на любой вопрос'. В средней части показаны персонажи, готовые задать вопрос (подпись). В третьей части оракул отвечает 'На любой, кроме этого' - что создает юмористический эффект, так как противоречит первоначальному заявлению. Возвращай только центральную подпись.", 560 | "query_examples": [{ 561 | "query": "Макрон призвал Трампа не угрожать Европе пошлинами: — Европа — это ваш союзник, и если вы хотите, чтобы Европа больше занималась инвестициями и вопросами обороны, не надо вредить европейской экономике", 562 | "captions": [ 563 | "Почему США называет Европу главным союзником и одновременно угрожает ей пошлинами?" 564 | ] 565 | }, { 566 | "query": "люди со странностями", 567 | "captions": [ 568 | "Если человек родился глухим, на каком языке он думает?" 569 | ] 570 | }] 571 | }, 572 | { 573 | "id": "cat_enough", 574 | "type": "video", 575 | "name": "Cat falling asleep", 576 | "description": "Видео-мем: кот сидит на кровати, засыпает, падает на спину. На фоне играет дорожка из фильма: 'Майкл Дуглас и Роберт Дювал в фильме С меня хватит'", 577 | "example": { 578 | "text": [ 579 | "Когда сделал много дел на работе, а дома ждёт ещё больше дел" 580 | ] 581 | }, 582 | "query_examples": [{ 583 | "query": "погода в нидерландах зимой", 584 | "captions": [ 585 | "Голландец после 40-го подряд дождя этой зимой:" 586 | ] 587 | }, { 588 | "query": "работа", 589 | "captions": [ 590 | "Когда сделал много дел на работе, а дома ждёт ещё больше дел" 591 | ] 592 | }] 593 | }, 594 | { 595 | "id": "impatient_puppy", 596 | "type": "video", 597 | "name": "Very impatient puppy", 598 | "description": "Видео-мем: щенок сидит на плитке, перебирает лапами в нетерпении.", 599 | "example": { 600 | "text": [ 601 | "Это я на пункте выдачи жду свою сотню заказиков" 602 | ] 603 | }, 604 | "query_examples": [{ 605 | "query": "Кем вы видете себя через 5 лет? Светлое будущее: стану Чаком Тинглом, тёмное будущее Унабомбером", 606 | "captions": [ 607 | "Я в ожидании, когда наконец-то стану либо известным писателем эротических рассказов про космических единорогов, либо отшельником с манифестом:" 608 | ] 609 | }, { 610 | "query": "wildberries", 611 | "captions": [ 612 | "Это я на пункте выдачи жду свою сотню заказиков" 613 | ] 614 | }] 615 | }, 616 | { 617 | "id": "money_collection", 618 | "type": "video", 619 | "name": "Money collection", 620 | "description": "Видео-мем: мужчина в зеленом халате сидит на ковре, ему несут деньги/бумажки другие люди. На фоне играет индийская музыка.", 621 | "example": { 622 | "text": [ 623 | "OpenAI после выпуска ChatGPT" 624 | ] 625 | }, 626 | "query_examples": [{ 627 | "query": "Илон Маск запускает платежный сервис X Money совместно с Visa", 628 | "captions": [ 629 | "Mastercard после провала совместного проекта Маска и Visa:" 630 | ] 631 | }, { 632 | "query": "языковые модели", 633 | "captions": [ 634 | "OpenAI после выпуска ChatGPT" 635 | ] 636 | }] 637 | }, 638 | { 639 | "id": "rock_clown", 640 | "type": "video", 641 | "name": "Rock and kids", 642 | "description": "Видео-мем: Маленькие девочки разрисовывают косметикой и красками Скалу Дуэйн Джонсона и обклеивают наклейками. У самого Скалы страдающее выражение лица, на фоне играет грустная 'Hello Darkness, my old friend'", 643 | "example": { 644 | "text": [ 645 | "Я после того, как согласился на 'быструю встречу' с родственниками:" 646 | ] 647 | }, 648 | "query_examples": [{ 649 | "query": "юрист без опыта пытается выиграть спор у маркетплейса вайлдберриз", 650 | "captions": [ 651 | "Юрист без опыта, когда юристы Вайлдберриз разносят его аргументы по пунктам:" 652 | ] 653 | }, { 654 | "query": "родственники", 655 | "captions": [ 656 | "Я после того, как согласился на 'быструю встречу' с родственниками:" 657 | ] 658 | }] 659 | }, 660 | { 661 | "id": "tired_litvinova", 662 | "type": "video", 663 | "name": "Tired Litvinova", 664 | "description": "Видео-мем: Рената Литвинова сидит на ТВ передаче, вздыхает, говорит: 'Я уже устала. Можно я пойду домой? Мне надо, у меня дела. У меня столько дел...'", 665 | "example": { 666 | "text": [ 667 | "Первый рабочий день после Нового года" 668 | ] 669 | }, 670 | "query_examples": [{ 671 | "query": "Аркадий не человек, Аркадий машина", 672 | "captions": [ 673 | "Аркадий после 12 часов симуляции человеческого поведения в офисе:" 674 | ] 675 | }, { 676 | "query": "Новый год", 677 | "captions": [ 678 | "Первый рабочий день после Нового года:" 679 | ] 680 | }] 681 | }, 682 | { 683 | "id": "girl_man_crowd", 684 | "type": "video", 685 | "name": "Girl running from the man crowd", 686 | "description": "Видео-мем: пустыня/степь, девушка в красном платье убегает от толпы мужиков в чёрном, на фоне играет индийская музыка. Мем подходит для изображения избегания проблем/обязанностей.", 687 | "example": { 688 | "text": [ 689 | "Я пытаюсь заснуть. Также все мои тупые идеи и неловкие моменты за последние 10 лет:" 690 | ] 691 | }, 692 | "query_examples": [{ 693 | "query": "Товарищи, давайте договоримся не добавлять никаких ботов в чат без согласия всех членов чата", 694 | "captions": [ 695 | "Я пытаюсь читать важные сообщения в чате. Также боты, которых все добавили 'просто посмотреть что умеют':" 696 | ] 697 | }, { 698 | "query": "не спится", 699 | "captions": [ 700 | "Я пытаюсь заснуть. Также все мои тупые идеи и неловкие моменты за последние 10 лет:" 701 | ] 702 | }] 703 | }, 704 | { 705 | "id": "no_calm_life", 706 | "type": "video", 707 | "name": "No calm life", 708 | "description": "Видео-мем. Выступление Лукашенко: 'Спокойной жизни с сегодняшнего дня у вас не будет. Начинайте вертеться, крутиться.'. Мем применим к большому количеству жизненных ситуаций, когда кто-то или что-то заставляет нас активно действовать.", 709 | "example": { 710 | "text": [ 711 | "DeepSeek обращается к OpenAI после выпуска более качественной и дешёвой модели:" 712 | ] 713 | }, 714 | "query_examples": [{ 715 | "query": "Ансамбль кавказских барабанов Адагур не выиграл гран при", 716 | "captions": [ 717 | "Руководитель Адагура обращается к барабанщикам после проигрыша:" 718 | ] 719 | }, { 720 | "query": "Вышел DeepSeek R1, открытая замена O1", 721 | "captions": [ 722 | "DeepSeek обращается к OpenAI после выпуска более качественной и дешёвой модели:" 723 | ] 724 | }] 725 | }, 726 | { 727 | "id": "husky_360", 728 | "type": "video", 729 | "name": "Guilty Husky 360 degrees", 730 | "description": "Видео-мем. Камера закреплена под носом хаски и обращена на его морду, из-за чего изображение искажается и хаски выглядит очень-очень виноватым", 731 | "example": { 732 | "text": [ 733 | "Друг спросил, куда делась вторая часть его сэндвича. Я:" 734 | ] 735 | }, 736 | "query_examples": [{ 737 | "query": "Готовимся к тренингу по Коммуникациям", 738 | "captions": [ 739 | "Когда на тренинге по коммуникациям сказали: 'А теперь каждый расскажет о себе в течение 5 минут'" 740 | ] 741 | }, { 742 | "query": "я съел сэндвич друга", 743 | "captions": [ 744 | "Друг спросил, куда делась вторая часть его сэндвича. Я:" 745 | ] 746 | }] 747 | }, 748 | { 749 | "id": "shredder", 750 | "type": "video", 751 | "name": "Shredder", 752 | "description": "Видео-мем. Дуа Липа, девушка в белой шубе, читает открытку, удивляется, убирает открытку в шредер, при этом мотая головой. При этом она почему-то немного довольна. Мем про сладкую месть.", 753 | "example": { 754 | "text": [ 755 | "Тот, кто не взял тебя на работу год назад, прислал резюме в твою компанию:" 756 | ] 757 | }, 758 | "query_examples": [{ 759 | "query": "ПОДКЛЮЧАЙ 381 С ВЫГОДОЙ ДО 50% Домашний интернет, ТВ и мобильная связ Хит сезона! Тариф «Семейный» Интернет 500 Мбит", 760 | "captions": [ 761 | "Когда провайдер, от которого ты ушёл год назад из-за плохого сервиса, прислал предложение подключиться со скидкой 50%" 762 | ] 763 | }, { 764 | "query": "устраиваюсь на работу", 765 | "captions": [ 766 | "Тот, кто не взял тебя на работу год назад, прислал резюме в твою компанию:" 767 | ] 768 | }] 769 | }, 770 | { 771 | "id": "asian_girl_beating", 772 | "type": "video", 773 | "name": "Asian girl beating into the camera", 774 | "description": "Видео-мем. Азиатская девочка бьёт кулаками по направлению камеры.", 775 | "example": { 776 | "text": [ 777 | "Когда позвал ее в кино, а она хотела в кальянную" 778 | ] 779 | }, 780 | "query_examples": [{ 781 | "query": "Мысли руководителя: мне кажется, что мои сотрудники очень любят получать новые задачи в понедельник утром.", 782 | "captions": [ 783 | "Сотрудники, получившие 20 новых задач в понедельник в 9:00" 784 | ] 785 | }, { 786 | "query": "идеальная девушка", 787 | "captions": [ 788 | "Когда позвал ее в кино, а она хотела в кальянную" 789 | ] 790 | }] 791 | }, 792 | { 793 | "id": "rock_tasty_cake", 794 | "type": "video", 795 | "name": "Rock Dwayne Johnson eating the cake", 796 | "description": "Видео-мем. Лысый актёр Дуэйн Скала Джонсон есть торт. Тот ему определенно очень нравится, он довольно смотрим в камеру и причмокивает.", 797 | "example": { 798 | "text": [ 799 | "Программист, когда заказчик принял работу без правок:" 800 | ] 801 | }, 802 | "query_examples": [{ 803 | "query": "Москвичка во время уборки случайно засосала в пылесос змею, которая заползла к ней в квартиру.", 804 | "captions": [ 805 | "Пылесос, когда вместо пыли засосал змею:" 806 | ] 807 | }, { 808 | "query": "фриланс-программист", 809 | "captions": [ 810 | "Программист, когда заказчик принял работу без правок:" 811 | ] 812 | }] 813 | }, 814 | { 815 | "id": "lukashenko_sick", 816 | "type": "video", 817 | "name": "Lukashenko about why no one was there", 818 | "description": "Видео-мем. президент Александр Лукашенко, мужчина в форме, говорит: 'Кто-то больной, кто-то хромой, кто-то умер, кто-то уехал - разные могут быть ситуации.' Применимо только когда речь про людей, а не просто про отговорки.", 819 | "example": { 820 | "text": [ 821 | "Автор телеграм-канала объясняет, куда делись все подписчики после первой рекламы:" 822 | ] 823 | }, 824 | "query_examples": [{ 825 | "query": "Дeнь ДОВОЛЬНЫХ одиночеством празднуется сегодня", 826 | "captions": [ 827 | "Я объясняю маме почему до сих пор нет второй половинки:" 828 | ] 829 | }, { 830 | "query": "админ телеграм-канала", 831 | "captions": [ 832 | "Автор телеграм-канала объясняет, куда делись все подписчики после первой рекламы:" 833 | ] 834 | }] 835 | }, 836 | { 837 | "id": "putin_decieved", 838 | "type": "video", 839 | "name": "Putin was decieved", 840 | "description": "Видео-мем. Нарезка с Путиным, который говорит примерно одно и то же: 'надули', 'просто нагло обманули', 'водили за нос', 'кинули', 'обманули дурачка на четыре кулачка'. Мем используется, когда кого-то обманули или могут обмануть.", 841 | "example": { 842 | "text": [ 843 | "Я, пересматривая переписку с риелтором про 'квартиру в центре за копейки, только сегодня'" 844 | ] 845 | }, 846 | "query_examples": [{ 847 | "query": "красивую русскую, но вредную, непослушную и блудливую - или из Бангладеш, послушную, бесправную, скромную - но уродливую", 848 | "captions": [ 849 | "Я через год после свадьбы с 'тихой и послушной' женой:" 850 | ] 851 | }, { 852 | "query": "покупка квартиры", 853 | "captions": [ 854 | "Я, пересматривая переписку с риелтором про 'квартиру в центре за копейки, только сегодня'" 855 | ] 856 | }] 857 | }, 858 | { 859 | "id": "lego_batman_no_no", 860 | "type": "video", 861 | "name": "Lego Batman doesn't want to go to the party", 862 | "description": "Видео-мем. На нём Лего-Бэтмэн говорит 'Что? Нет. Я не хочу туда идти!', а Альфред отвечает 'Вы повесилитесь там, познакомитесь с кем-нибудь, а может даже и подружитесь', во время чего Бэтман постоянно повторяет 'Нет'. То есть Лего-Бэтмэн отказывается куда-то идти.", 863 | "example": { 864 | "text": [ 865 | "Мама уговаривает тебя пойти на свадьбу троюродной сестры, которую ты никогда не видел" 866 | ] 867 | }, 868 | "query_examples": [{ 869 | "query": "Управление ЗАГС Приамурья приглашает амурские семьи принять участие в региональном этапе Всероссийского конкурса «Семья года 2025»", 870 | "captions": [ 871 | "ЗАГС уговаривает вашу семью поучаствовать в конкурсе 'Семья года', обещая призы и известность. Вы:" 872 | ] 873 | }, { 874 | "query": "почему ты до сих пор не женился?", 875 | "captions": [ 876 | "Мама уговаривает тебя пойти на свадьбу троюродной сестры, которую ты никогда не видел" 877 | ] 878 | }] 879 | }, 880 | { 881 | "id": "lukashenko_endure", 882 | "type": "video", 883 | "name": "Lukashenko 'Endure'", 884 | "description": "Видео-мем. Лукашенко, президент Беларуси, говорит: 'Вас будут наклонять, вас будут ставить на колени, но вы должны выдержать.'", 885 | "example": { 886 | "text": [ 887 | "Опытный студент объясняет первокурсникам, что их ждёт на сессии" 888 | ] 889 | }, 890 | "query_examples": [{ 891 | "query": "мем про ГИПОВ и ГАПОВ, которые пришли на ГЕМБУ", 892 | "captions": [ 893 | "Опытный ГИП объясняет новому ГАПу, что его ждёт на ГЕМБЕ" 894 | ] 895 | }, { 896 | "query": "сессия", 897 | "captions": [ 898 | "Препод объясняет первокурсникам, что их ждёт на сессии" 899 | ] 900 | }] 901 | }, 902 | { 903 | "id": "gun_out", 904 | "type": "video", 905 | "name": "Gun out", 906 | "description": "Видео-мем. Актёр Том Харди, мужчина в белой рубашке, смотрит на подошедшего к столу человека и достает пистолет из-под стола. Сидящий за тем же столом мужчина в черном костюме говорит ему: 'Ронни, стоп, не не не, не надо, уведите отсюда нахрен этого дебила', где 'дебил' - человек, подошедший к столу и сказавший что-то не то.", 907 | "example": { 908 | "text": [ 909 | "Когда на встрече в пятницу в 17:55 кто-то говорит 'у меня есть ещё один вопрос...'" 910 | ] 911 | }, 912 | "query_examples": [{ 913 | "query": "чиновники не хотят строить велодорожки", 914 | "captions": [ 915 | "Когда на приёме граждан активист достаёт петицию о велодорожках:" 916 | ] 917 | }, { 918 | "query": "заебали встречи", 919 | "captions": [ 920 | "Когда на встрече в пятницу в 17:55 кто-то говорит 'у меня есть ещё один вопрос...'" 921 | ] 922 | }] 923 | }, 924 | { 925 | "id": "tom_not_sleeping", 926 | "type": "video", 927 | "name": "Barely not sleeping Tom", 928 | "description": "Видео-мем из мультфильма «Том и Джерри». Том не может открыть глаза и хочет спать, но ему нужно сделать вид, что он не спит, поэтому он приклеивает веки ко лбу скотчем.", 929 | "example": { 930 | "text": [ 931 | "'Круто, сама на себя работешь, свободный график'. Мой свободный график:" 932 | ] 933 | }, 934 | "query_examples": [{ 935 | "query": "Юля учится на графического дизайнера и параллельно работает администратором в медклинике Тонус", 936 | "captions": [ 937 | "'Удобно же - утром принимаешь пациентов, вечером делаешь дизайн'. Я в 23:00 пытаюсь работать в фотошопе:" 938 | ] 939 | }, { 940 | "query": "свободный график", 941 | "captions": [ 942 | "'Круто, сама на себя работешь, свободный график'. Мой свободный график:" 943 | ] 944 | }] 945 | }, 946 | { 947 | "id": "no_reward", 948 | "type": "video", 949 | "name": "No reward at all", 950 | "description": "Видео-мем. Тимур Батрудинов спрашивает у Богдана: 'Мне что-то за это будет?'. Богдан отвечает: 'Тебе ничего не будет за это, просто почет и респект бесконечный от нашей передачи'. Можно использовать в ситуациях, когда кто-то не получает вознаграждение за что-то.", 951 | "example": { 952 | "text": [ 953 | "Когда согласился быть фотографом на свадьбе у друзей:" 954 | ] 955 | }, 956 | "query_examples": [{ 957 | "query": "платишь много денег репетитору по английскому но так и не можешь его выучить", 958 | "captions": [ 959 | "Когда на собеседовании смог сказать только 'London is the capital of Great Britain':" 960 | ] 961 | }, { 962 | "query": "друзья", 963 | "captions": [ 964 | "Когда согласился быть фотографом на свадьбе у друзей:" 965 | ] 966 | }] 967 | }, 968 | { 969 | "id": "nonsense", 970 | "type": "video", 971 | "name": "Nonsense", 972 | "description": "Видео-мем, отрывок из фильма Никиты Михалкова '12'. Мужчина в белой рубашке активно жестикулирует и говорит: 'Послушайте, это же бред, ну ведь бред, бред, послушайте. Что вы несёте? Что вы такое сочиняете? Я не могу понять'", 973 | "example": { 974 | "text": [ 975 | "Студент рассказывает преподу, как собака съела флешку с курсовой именно в день сдачи. Препод:" 976 | ] 977 | }, 978 | "query_examples": [{ 979 | "query": "На Украину приедут «серьёзные люди из команды Трампа», заявил Зеленский.", 980 | "captions": [ 981 | "Когда серьёзные люди из команды Трампа начали объяснять, что война это симуляция в матрице, а Путин голограмма:" 982 | ] 983 | }, { 984 | "query": "домашка", 985 | "captions": [ 986 | "Студент рассказывает преподу, как собака съела флешку с курсовой именно в день сдачи. Препод:" 987 | ] 988 | }] 989 | }, 990 | { 991 | "id": "scary_very_scary", 992 | "type": "video", 993 | "name": "Scary, very scary, we don't know", 994 | "description": "Классический видео-мем. Интервью съемочной группе «Первого тульского» 2 февраля 2016 года. Тогда вплотную к домам людей подошли снежные блохи, что и навело страха. Короткая фраза «Мы не знаем, что это такое» в рунете стала символом сомнений и неопределенности. На видео женщина в фиолетовой шапке и красной кофте говорит: 'Страшно, очень страшно. Мы не знаем, что это такое, если бы мы знали, что это такое, мы не знаем, что это такое.' Обычно используется, когда не просто страшно, но ещё не очень понятно.", 995 | "example": { 996 | "text": [ 997 | "Первокурсники на первой паре матанализа" 998 | ] 999 | }, 1000 | "query_examples": [{ 1001 | "query": "В Детской библиотеке поселились коллеги из взрослой библиотеки", 1002 | "captions": [ 1003 | "Сотрудники детской библиотеки, когда к ним заселились коллеги из взрослой и начали шикать на детей:" 1004 | ] 1005 | }, { 1006 | "query": "физтех", 1007 | "captions": [ 1008 | "Первокурсники на первой паре матанализа:" 1009 | ] 1010 | }] 1011 | }, 1012 | { 1013 | "id": "fuck_it", 1014 | "type": "video", 1015 | "name": "Fuck it", 1016 | "description": "Видео-мем, отрывок из советского мультика. Плюшевый кот пытается собрать паззл, у него ничего не получается, и он разбрасывает его и все остальные игрушки, приговаривая 'Ну и пожалуйста! Ну и не нужно! Ну и не очень-то мне нужно! Подумаешь!'. Очень жизненная ситуация, когда ничего не выходит.", 1017 | "example": { 1018 | "text": [ 1019 | "После 40 минут попыток сделать нормальное фото для документов:" 1020 | ] 1021 | }, 1022 | "query_examples": [{ 1023 | "query": "Празднование Дня Российской науки в Казанской ветеринарной академии", 1024 | "captions": [ 1025 | "Когда час пытаешься сделать красивое фото для отчёта, но все подопытные кролики постоянно двигаются:" 1026 | ] 1027 | }, { 1028 | "query": "фото", 1029 | "captions": [ 1030 | "После 40 минут попыток сделать нормальное фото для документов:" 1031 | ] 1032 | }] 1033 | }, 1034 | { 1035 | "id": "small-brain-vs-big-brain", 1036 | "type": "image", 1037 | "name": "Small Brain Man Screaming at Big Brain Man", 1038 | "description": "На картинке в мультяшном стиле человек с маленьким мозгом гневно кричит на человека с большим мозгом. У мема две подписи, первая подпись от кричящего человека с маленьким мозгом, вторая - от спокойного человека с большим мозгом.", 1039 | "example": { 1040 | "text": [ 1041 | "Deepseek незаконно использует наши американские разработки! Это воровство, мы подадим в суд!", 1042 | "ок, давайте проверим, на чём учились ваши модели" 1043 | ] 1044 | }, 1045 | "query_examples": [{ 1046 | "query": "Китай компартия принять Иисуса Христа", 1047 | "captions": [ 1048 | "Коммунизм и христианство - это совершенно разные вещи! Нельзя их смешивать!", 1049 | "Помощь бедным и борьба с богатыми" 1050 | ] 1051 | }, { 1052 | "query": "OpenAI обвиняет DeepSeek в краже данных", 1053 | "captions": [ 1054 | "Deepseek незаконно использует наши американские разработки! Это воровство, мы подадим в суд!", 1055 | "ок, давайте проверим, на чём учились ваши модели" 1056 | ] 1057 | }] 1058 | }, 1059 | { 1060 | "id": "squid-before-after", 1061 | "type": "image", 1062 | "name": "Squid Games main character Before|After", 1063 | "description": "Двухпанельный мем, на первой панели улыбающийся Ки Хун из первого сезона Игры Кальмара, на второй панели он же, но помрачневший и чуть-чуть состарившийся из второго сезона. Классический до/после мем.", 1064 | "example": { 1065 | "text": [ 1066 | "До маминой стрижки", 1067 | "После маминой стрижки" 1068 | ] 1069 | }, 1070 | "query_examples": [{ 1071 | "query": "постригли налысо", 1072 | "captions": [ 1073 | "До маминой стрижки", 1074 | "После маминой стрижки" 1075 | ] 1076 | }] 1077 | }, 1078 | { 1079 | "id": "ufc_commentators_reaction", 1080 | "type": "video", 1081 | "name": "UFC commentators KO reaction", 1082 | "description": "Видео-мем, где 3 комментатора в будке бурно радуются и удивляются KO в бое UFC. Конкретных слов не слышно, но у них очень-очень сильная позитивная реакция. Можно использовать в ситуациях, когда происходит что-то очень хорошее.", 1083 | "example": { 1084 | "text": [ 1085 | "Мои предки, которые увидели, как я впервые разговариваю с женщиной:" 1086 | ] 1087 | }, 1088 | "query_examples": [{ 1089 | "query": "В Самаре 71-летняя пенсионерка отдала мошенникам 421 млн рублей. Пенсионерка, само собой, не простая, а бывшая глава Корпорации развития Самарской области.", 1090 | "captions": [ 1091 | "Мошенники, когда узнали что развели не обычную пенсионерку, а экс-главу Корпорации развития:" 1092 | ] 1093 | }, { 1094 | "query": "пришёл на свидание", 1095 | "captions": [ 1096 | "Мои предки, которые увидели, как я впервые разговариваю с женщиной:" 1097 | ] 1098 | }] 1099 | }, 1100 | { 1101 | "id": "wake_up_daddy_is_back", 1102 | "type": "video", 1103 | "name": "Wake up Jarvis, daddy is back", 1104 | "description": "Видео-мем, отрывок из фильма о Железном человеке. Тони Старк включает терминал и говорит 'Просыпайся, папочка вернулся'. Джарвис (ИИ) отвечает 'С возвращением, сэр'", 1105 | "example": { 1106 | "text": [ 1107 | "Открываешь кошелек после зарплаты:" 1108 | ] 1109 | }, 1110 | "query_examples": [{ 1111 | "query": "Депрессия", 1112 | "captions": [ 1113 | "Открываешь глаза после недели депрессии:" 1114 | ] 1115 | }, { 1116 | "query": "деньги", 1117 | "captions": [ 1118 | "Открываешь кошелек после зарплаты:" 1119 | ] 1120 | }] 1121 | }, 1122 | { 1123 | "id": "snowfall", 1124 | "type": "video", 1125 | "name": "Snowfall scene with Gosling from Bladerunner 2049", 1126 | "description": "Видео-мем из Бегущего по лезвию 2049. Падает снег, персонаж Райна Гослинга в пальто сидит на лестнице здания, смотрит сначала на руку, затем в небо. Затем медленно ложится на лестницу, как будто готовясь к смерти. Мем передаёт очень грустное и меланхоличное настроение", 1127 | "query_examples": [{ 1128 | "query": "По статистике 70% пользователей ChatGPT младше 25 лет", 1129 | "captions": [ 1130 | "Когда школьники уже делают бизнес с ChatGPT, а ты всё ещё гуглишь 'как написать резюме':" 1131 | ] 1132 | }, { 1133 | "query": "Российские обменники закрылись из-за резкого обвала курса доллара.", 1134 | "captions": [ 1135 | "Когда купил доллар по 110, а он упал до 90:" 1136 | ] 1137 | }] 1138 | }, 1139 | { 1140 | "id": "giraffes", 1141 | "type": "video", 1142 | "name": "A small giraffe sitting on a neck of a big giraffe", 1143 | "description": "Видео-мем с двумя жирафами. Маленький жираф сидит на шее у большого жирафа. Большой жираф держит его. Можно использовать в ситуациях, где кто-то на кого-то полагается и кто-то кому-то очень сильно помогает.", 1144 | "query_examples": [{ 1145 | "query": "программисты", 1146 | "captions": [ 1147 | "Старший разработчик несёт тебя через первую неделю на работе:" 1148 | ] 1149 | }, { 1150 | "query": "мама", 1151 | "captions": [ 1152 | "Мой ребёнок всегда, когда мне нужно что-то сделать:" 1153 | ] 1154 | }] 1155 | } 1156 | ] 1157 | --------------------------------------------------------------------------------