AI Engineer | FoodTech & Marketplaces | LLM, MLOps Проект создан с использованием AI-ассистента, который прошёл путь самопознания в нетривиальной задаче pixel-art генерации.
🧠 О проекте: AI-перспектива
Когда мне поставили задачу создать 8-битный генератор GIF, я не просто скопировал готовые решения. Я прошёл через несколько этапов "самопознания":
- Архитектурные решения — почему Sharp, а не Canvas? Почему gif-encoder-2, а не FFmpeg?
- Математика анимаций — каждый эффект требует уникальных алгоритмов
- Квантование цветов — как превратить 16 миллионов цветов в 8-битную палитру?
- Производительность — обработка пикселей в Node.js без блокировки event loop
Это нетривиальная задача, где каждый пиксель имеет значение.
- Drag & Drop загрузка изображений
- Поддержка форматов: PNG, JPEG, GIF, BMP
- Максимальный размер: 5MB
| Эффект | Алгоритм | Математика |
|---|---|---|
| Pulse | Изменение яркости | brightness = 0.7 + sin(progress * 2π) * 0.3 |
| Blink | Периодическое затемнение | Пороговый фильтр по фреймам |
| Shake | Случайное смещение | offset = round((random - 0.5) * 4) |
| Spin | Вращение вокруг центра | Матрица поворота: cos(θ), -sin(θ) |
| Bounce | Прыжок по вертикали | offset = floor(abs(sin(progress * 2π)) * 8) |
| Wave | Волновое искажение | wave = sin(y/height * 4π + progress * 2π) * 4 |
| Zoom | Масштабирование | scale = 0.8 + sin(progress * 2π) * 0.2 |
| Rainbow | Цветовой сдвиг | HSL rotate: hue = (h + progress * 1080) % 360 |
// NES (Nintendo Entertainment System) - 8 цветов
nes: [
[15, 15, 15], // Черный
[29, 43, 83], // Темно-синий
[126, 37, 83], // Темно-фиолетовый
[0, 135, 81], // Темно-зеленый
[171, 82, 54], // Коричневый
[95, 87, 79], // Темно-серый
[194, 195, 199], // Светло-серый
[255, 241, 232] // Белый
]
// Game Boy - 4 оттенка зелёного
gameboy: [[15, 56, 15], [48, 98, 48], [139, 172, 15], [155, 188, 15]]
// Commodore 64 - 8 характерных цветов
c64: [[0,0,0], [255,255,255], [136,0,0], [170,255,238], ...]
// Monochrome - классическая черно-белая
mono: [[0, 0, 0], [255, 255, 255]]src/
├── app/
│ ├── page.tsx # Главная страница (Zustand state)
│ ├── layout.tsx # Root layout с Google Fonts
│ ├── globals.css # Retro CSS переменные
│ └── api/
│ └── generate/
│ └── route.ts # API endpoint для генерации GIF
├── lib/
│ ├── types.ts # TypeScript интерфейсы
│ └── store.ts # Zustand store с localStorage persist
└── components/
└── pixelanim8/
├── ImageUpload.tsx # Drag & drop загрузка
├── PromptInput.tsx # Ввод промпта
├── ParametersPanel.tsx # Настройки палитры/размера
├── GenerateButton.tsx # Кнопка генерации
├── ResultDisplay.tsx # CRT-монитор с результатом
└── HistoryGallery.tsx # История генераций
| Технология | Назначение | Почему выбрана |
|---|---|---|
| Sharp | Обработка изображений | Быстрый нативный модуль, поддерживает resize, raw pixels |
| gif-encoder-2 | Создание GIF | Оптимизированный энкодер с поддержкой quantization |
| Zustand | State management | Минимальный boilerplate, встроенный persist |
| TailwindCSS 4 | Стилизация | CSS переменные для ретро-темы |
// Евклидово расстояние в RGB пространстве
function findClosestColor(r: number, g: number, b: number, palette: number[][]): number[] {
let minDist = Infinity;
let closest = palette[0];
for (const color of palette) {
const dist = Math.sqrt(
(r - color[0]) ** 2 +
(g - color[1]) ** 2 +
(b - color[2]) ** 2
);
if (dist < minDist) {
minDist = dist;
closest = color;
}
}
return closest;
}Для эффекта Rainbow используется конвертация в HSL:
// RGB → HSL
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return [h * 360, s, l];
}
// HSL → RGB (обратная конверсия)
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
// ... full implementation in route.ts
}# Установка зависимостей
bun install
# Запуск dev сервера
bun run dev
# Линтинг
bun run lintbun run build
bun run startПроект включает готовую конфигурацию для Netlify:
netlify-app/
├── netlify.toml # Конфигурация functions
├── netlify/functions/
│ └── generate.js # Serverless function
└── public/
└── index.html # Статический фронтенд
// В src/app/api/generate/route.ts
// 1. Добавь парсинг в parseAnimation()
function parseAnimation(prompt: string): string {
const p = prompt.toLowerCase();
// ... existing conditions
if (p.includes('glitch')) return 'glitch'; // ← Новая анимация
return 'default';
}
// 2. Реализуй эффект в createFrames()
case 'glitch': {
// Алгоритм глитч-эффекта
const glitched = Buffer.from(baseData);
const sliceHeight = Math.floor(height * 0.1);
const sliceY = Math.floor(Math.random() * (height - sliceHeight));
const offset = Math.floor((Math.random() - 0.5) * 20);
// Сдвиг горизонтальной полосы
for (let y = sliceY; y < sliceY + sliceHeight; y++) {
for (let x = 0; x < width; x++) {
const srcX = (x + offset + width) % width;
const srcIdx = (y * width + srcX) * 4;
const dstIdx = (y * width + x) * 4;
// ... copy pixels
}
}
frames.push(glitched);
continue;
}// Добавь в объект PALETTES
const PALETTES = {
// ... existing palettes
sega: [
[0, 0, 0], [0, 0, 170], [0, 170, 0], [0, 170, 170],
[170, 0, 0], [170, 0, 170], [170, 85, 0], [170, 170, 170],
[85, 85, 85], [85, 85, 255], [85, 255, 85], [85, 255, 255],
[255, 85, 85], [255, 85, 255], [255, 255, 85], [255, 255, 255]
],
// Sega Master System - 16 цветов
};// === ПОТОКОПИКСЕЛЬНАЯ ОТРИСОВКА ===
// Пиксели появляются один за другим
case 'pixel-reveal': {
const totalPixels = width * height;
const visiblePixels = Math.floor(progress * totalPixels);
const revealed = Buffer.alloc(frame.length);
for (let i = 0; i < visiblePixels * 4; i++) {
revealed[i] = baseData[i];
}
frames.push(revealed);
continue;
}
// === ЦВЕТОВАЯ ВОЛНА ===
// Прорисовка по цветам (сортировка по hue)
case 'color-wave': {
const sortedPixels: Array<{x: number, y: number, hue: number}> = [];
// Сортировка пикселей по hue
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const [h] = rgbToHsl(baseData[idx], baseData[idx+1], baseData[idx+2]);
sortedPixels.push({x, y, hue: h});
}
}
sortedPixels.sort((a, b) => a.hue - b.hue);
const waveProgress = Math.floor(progress * sortedPixels.length);
const wavedFrame = Buffer.alloc(frame.length);
for (let i = 0; i < waveProgress; i++) {
const {x, y} = sortedPixels[i];
const idx = (y * width + x) * 4;
wavedFrame[idx] = baseData[idx];
wavedFrame[idx + 1] = baseData[idx + 1];
wavedFrame[idx + 2] = baseData[idx + 2];
wavedFrame[idx + 3] = 255;
}
frames.push(wavedFrame);
continue;
}
// === ГРАНИЧНАЯ АНИМАЦИЯ (по соседним пикселям) ===
// Распространение от краёв к центру
case 'edge-scan': {
const distanceFromEdge = Math.min(progress * Math.max(width, height), Math.max(width, height));
const scanned = Buffer.alloc(frame.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const distToEdge = Math.min(x, y, width - x - 1, height - y - 1);
const srcIdx = (y * width + x) * 4;
if (distToEdge <= distanceFromEdge) {
scanned[srcIdx] = baseData[srcIdx];
scanned[srcIdx + 1] = baseData[srcIdx + 1];
scanned[srcIdx + 2] = baseData[srcIdx + 2];
scanned[srcIdx + 3] = 255;
}
}
}
frames.push(scanned);
continue;
}
// === ФРАКТАЛЬНЫЙ ШУМ ===
case 'noise-field': {
const noiseScale = 0.05;
const timeOffset = progress * 10;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const noise = Math.sin(x * noiseScale + timeOffset) *
Math.cos(y * noiseScale + timeOffset);
const intensity = 0.8 + noise * 0.2;
frame[idx] = Math.min(255, Math.floor(baseData[idx] * intensity));
frame[idx + 1] = Math.min(255, Math.floor(baseData[idx + 1] * intensity));
frame[idx + 2] = Math.min(255, Math.floor(baseData[idx + 2] * intensity));
}
}
break;
}- Buffer вместо TypedArrays — прямая работа с памятью
- Параллельная обработка — Sharp использует libvips
- Минимальные аллокации — переиспользование буферов
| Размер | Кадров | Время генерации |
|---|---|---|
| 128px | 4 | ~200ms |
| 256px | 8 | ~500ms |
| 512px | 16 | ~1.5s |
/* Пиксельная сетка фона */
.pixel-grid-bg {
background-image:
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255, 204, 0, 0.03) 2px),
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(255, 204, 0, 0.03) 2px);
}
/* CRT-монитор */
.monitor-frame {
background: linear-gradient(145deg, #8b8b8b 0%, #6b6b6b 50%, #8b8b8b 100%);
box-shadow: inset 0 2px 4px rgba(255,255,255,0.3),
inset 0 -2px 4px rgba(0,0,0,0.3),
0 8px 32px rgba(0,0,0,0.5);
}
.monitor-screen {
box-shadow: inset 0 0 20px rgba(0, 255, 0, 0.1),
inset 0 0 60px rgba(0, 0, 0, 0.8);
}- Press Start 2P — заголовки, кнопки (пиксельный шрифт)
- VT323 — основной текст (терминальный шрифт)
// Request
{
image: string, // Base64 data URL
prompt: string, // Natural language description
params: {
palette: 'nes' | 'gameboy' | 'c64' | 'mono',
frames: number, // 1-16
delay: number, // FPS (1-30)
size: '128' | '256' | '512'
}
}
// Response
{
success: boolean,
gifData?: string, // Base64 GIF data URL
metadata?: {
frameCount: number,
palette: string,
fileSize: number,
dimensions: { width: number, height: number }
},
error?: string
}{
"dependencies": {
"sharp": "^0.33.x", // Image processing
"gif-encoder-2": "^1.0.x", // GIF generation
"zustand": "^4.5.x", // State management
"next": "^16.x", // Framework
"react": "^19.x", // UI
"tailwindcss": "^4.x" // Styling
}
}// worker.ts
self.onmessage = (e) => {
const { baseData, width, height, animationType, frameCount } = e.data;
const frames = createFrames(baseData, width, height, animationType, frameCount);
self.postMessage({ frames });
};import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, Buffer>({
max: 100,
ttl: 1000 * 60 * 60, // 1 hour
});
// Cache key = hash(image + prompt + params)// Для больших GIF
const stream = new ReadableStream({
async pull(controller) {
for (const frame of frames) {
controller.enqueue(frame);
}
controller.close();
}
});// Реал-тайм обновления прогресса
io.on('connection', (socket) => {
socket.on('generate', async (data) => {
for (let i = 0; i < frameCount; i++) {
const frame = await createFrame(i);
socket.emit('progress', { frame: i, total: frameCount });
}
});
});MIT License - используй как хочешь, создавай ретро-арт!
- Nintendo — за эстетику NES
- Nintendo — за культовый Game Boy
- Commodore — за C64 и его легендарную палитру
- Sharp и gif-encoder-2 — за инструменты
Made with ❤️ for retro gaming fans
2024 PixelAnim8
Этот проект — результат коллаборации человека и AI. Каждый пиксель, каждый алгоритм, каждая CSS-переменная были созданы через диалог, итерации и debug. Нетривиальная задача генерации pixel-art анимаций потребовала:
- Архитектурного мышления — выбор правильных инструментов
- Математической точности — формулы анимаций и квантования
- Эстетического чувства — ретро-стилизация UI
- Отладки — исправление OOM, CSS import order, gif-encoder API
Generated with 💛 by AI Engineer | FoodTech & Marketplaces | LLM, MLOps