«Крошка сын к отцу пришел и спросила кроха...»

Ну не сын на самом деле, а дочка, но пришла и спросила: «Паааап, у подруги тут ДР, вытащи мне из фотоархива все фото где мы с ней вместе». Да легко!

Но тут выяснилось, что и не так то легко. Дело в том, что еще в 22-м году, по понятным причинам, я перенес фотоархив с Google Photos, где распознавание лиц было уже тогда, на Яндекс Диск, где его нет до сих пор.

Но «тыж программист» (хоть и бывший, но бывших программистов не бывает!) да к тому же с интересом изучающий современный креативный подход Vibe Coding. На одной чаше весов было монотонное перелистывание тысяч фотографий, на другой — поразвлечься с Дипсиком и написать тулзу, которая распознает лица на моих фотографиях в Яндекс Диске. И понеслась...

В результате первого совещания с ИИ было выдвинуто предложение написать программу на JS, которая вытянет фотографии с Yandex Disk, распознает их в Yandex Vision (по тем же причинам что и переезд фотобанка в 22-м году зарубежные сервисы не рассматривал) и сохранит информацию в Yandex DB. Сказано - сделано. Буквально полчаса - и первый рабочий прототип готов.

Но тут выяснилось что полученная программа просто определяет наличие лиц на фото. Но не распознает их. На вопрос к Дипсику "с чего это вдруг" он ни капли не сомневаясь ответил "да не вопрос, просто добавь в вызове Yandex Vision FACE_RECOGNITION к FACE_DETECTION и будет тебе счастье". Да вот только выяснилось что FACE_RECOGNITION яндекс не умеет, только detection.

Дальше был мучительный перебор различных вариантов решений (к слову, я в этой теме не разбираюсь вот совсем - так что приходилось полностью полагаться на то, что мне предлагал уважаемый DeepSeek). Из вариантов которые я перебрал:

  • Полностью локальное решение на tenzorFlow

  • Опять-таки локальное решение на openCV

  • Полностью локальное решение на ONNX

По ряду причин каждое из них не взлетело. Будь у меня больше опыта в теме, уверен, можно было бы заставить работать (например тот же tenzorflow). Но после пары дней экспериментов (увы, блицкриг, который мне обещала заработавшая через полчаса первая версия, не случился) я остановился на варианте:

  • Определение лиц на фото с использованием Yandex Vision

  • Построение embedding-а для лиц с использованием ONNX + InsightFace

Примерно в это же время я (опять-таки, в качестве своих экспериментов с подходом к Vibe Coding) сменил дипсик на Claude Code. В итоге получился следущий код:

ВАЖНО! Данный код создан для быстрого решения задачи, по сути дела прототип в котором я просто эксперементировал с технологиями. Он на 90% создан ИИ. Ни в коем случае не используете его в Production - только как некоторый пример того как это может работать и для собственных идей.

Основные классы:

  • yandex-vision-face-detector.js - класс, который распознает лица при помощи Yandex Vision. Тут в принципе все просто. Важно только помнить что Vision принимает изображения до 4 мб, сжатие изображения я вставил в вызывающем коде, чтобы потом сразу из сжатого изображения вырезать лица по тем координатам, что возвращает YandexVisionFaceDetector.detectFaces

// yandex-vision-face-detector.js
const axios = require('axios');

class YandexVisionFaceDetector {
    constructor(apiKey, folderId) {
        this.apiKey = apiKey;
        this.folderId = folderId;
        this.baseURL = 'https://vision.api.cloud.yandex.net/vision/v1';
    }

    /**
     * Детекция лиц через Yandex Vision API
     */
    async detectFaces(imageBuffer) {
        try {
            const base64Image = imageBuffer.toString('base64');

            const response = await axios.post(
                `${this.baseURL}/batchAnalyze`,
                {
                    folderId: this.folderId,
                    analyze_specs: [{
                        content: base64Image,
                        features: [{
                            type: "FACE_DETECTION"
                        }]
                    }]
                },
                {
                    headers: {
                        'Authorization': `Api-Key ${this.apiKey}`,
                        'Content-Type': 'application/json'
                    },
                    timeout: 30000
                }
            );

            return this.parseVisionResponse(response.data);

        } catch (error) {
            console.error('❌ Ошибка Yandex Vision API:', error.response?.data || error.message);
            throw new Error(`Yandex Vision API error: ${error.message}`);
        }
    }

    /**
     * Парсинг ответа от Yandex Vision
     */
    parseVisionResponse(visionData) {
        const faces = visionData.results?.[0]?.results?.[0]?.faceDetection?.faces;

        if (!faces || faces.length === 0) {
            return [];
        }

        return faces.map((face, index) => {
            const vertices = face.boundingBox.vertices;

            const xCoords = vertices.map(v => v.x);
            const yCoords = vertices.map(v => v.y);

            const bbox = [
                Math.min(...xCoords),
                Math.min(...yCoords),
                Math.max(...xCoords),
                Math.max(...yCoords)
            ];

            return {
                bbox: bbox,
                confidence: 1.0,
                landmarks: face.landmarks,
                attributes: face.attributes
            };
        });
    }
}

module.exports = YandexVisionFaceDetector;
  • onnx-face-embedding-service.js - класс, который строит вектор по изображению лица с использованием ONNX + InsightFace. Для его работы надо только заранее скачать файл insightface.onnx, например тут и положить его в папку ./models.

const ort = require('onnxruntime-node');
const sharp = require('sharp');
const path = require('path');

class OnnxFaceEmbeddingService {
  constructor() {
    this.session = null;
    this.inputSize = 112; // Стандартный размер для InsightFace моделей
  }

  /**
   * Инициализация сервиса и загрузка модели
   * @param {string} modelsPath - Путь к папке с моделями
   */
  async initialize(modelsPath) {
    try {
      const modelPath = path.join(modelsPath, 'insightface.onnx');

      // Создаем сессию ONNX Runtime
      this.session = await ort.InferenceSession.create(modelPath, {
        executionProviders: ['cpu'], // Можно использовать 'cuda' если есть GPU
        graphOptimizationLevel: 'all'
      });

      console.log('ONNX модель успешно загружена:', modelPath);
      console.log('Входные тензоры:', this.session.inputNames);
      console.log('Выходные тензоры:', this.session.outputNames);

      return true;
    } catch (error) {
      console.error('Ошибка при загрузке модели:', error);
      throw new Error(`Не удалось загрузить модель: ${error.message}`);
    }
  }

  /**
   * Извлечение области лица из изображения
   * @param {Buffer} inputBuffer - Исходное изображение
   * @param {Array} bbox - Массив из 4 чисел [x1, y1, x2, y2] - координаты верхнего левого и нижнего правого углов
   * @param {number} padding - Отступ вокруг лица (по умолчанию 0.1)
   * @returns {Promise<Buffer>} - Вырезанное изображение лица
   */
  async extractFaceRegion(inputBuffer, bbox, padding = 0.1) {
    const [x1, y1, x2, y2] = bbox;

    const width = x2 - x1;
    const height = y2 - y1;

    // Добавляем отступ
    const x = Math.max(0, Math.floor(x1 - width * padding));
    const y = Math.max(0, Math.floor(y1 - height * padding));
    const paddedWidth = Math.floor(width * (1 + 2 * padding));
    const paddedHeight = Math.floor(height * (1 + 2 * padding));

    // Вырезаем область лица
    const faceBuffer = await sharp(inputBuffer)
      .extract({ left: x, top: y, width: paddedWidth, height: paddedHeight })
      .jpeg()
      .toBuffer();

    return faceBuffer;
  }

  /**
   * Получение embedding для одного изображения лица
   * @param {Buffer} faceBuffer - Изображение лица (уже вырезанное)
   * @returns {Array} - Нормализованный embedding
   */
  async getFaceEmbedding(faceBuffer) {
    if (!this.session) {
      throw new Error('Модель не инициализирована. Вызовите initialize() сначала.');
    }

    try {
      // Изменяем размер и получаем raw данные
      const faceImage = await sharp(faceBuffer)
        .resize(this.inputSize, this.inputSize, {
          fit: 'fill',
          kernel: sharp.kernel.lanczos3
        })
        .raw()
        .toBuffer();

      // Конвертируем в Float32Array и нормализуем
      const pixels = new Uint8Array(faceImage);
      const float32Data = new Float32Array(3 * this.inputSize * this.inputSize);

      // Преобразуем RGB в формат для модели
      // Формат: CHW (Channels, Height, Width)
      for (let i = 0; i < this.inputSize * this.inputSize; i++) {
        // R канал
        float32Data[i] = (pixels[i * 3] - 127.5) / 127.5;
        // G канал
        float32Data[this.inputSize * this.inputSize + i] = (pixels[i * 3 + 1] - 127.5) / 127.5;
        // B канал
        float32Data[2 * this.inputSize * this.inputSize + i] = (pixels[i * 3 + 2] - 127.5) / 127.5;
      }

      // Создаем входной тензор
      const inputTensor = new ort.Tensor(
        'float32',
        float32Data,
        [1, 3, this.inputSize, this.inputSize]
      );

      // Запускаем inference
      const feeds = {};
      feeds[this.session.inputNames[0]] = inputTensor;
      const results = await this.session.run(feeds);

      // Получаем embedding из выходного тензора
      const outputTensor = results[this.session.outputNames[0]];
      const embedding = Array.from(outputTensor.data);

      // Нормализуем embedding (L2 нормализация)
      const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0));
      const normalizedEmbedding = embedding.map(val => val / norm);

      return normalizedEmbedding;
    } catch (error) {
      console.error('Ошибка при генерации embedding:', error);
      throw error;
    }
  }

  /**
   * Вычисление косинусного сходства между двумя embeddings
   * @param {Array} embedding1 - Первый embedding
   * @param {Array} embedding2 - Второй embedding
   * @returns {number} - Косинусное сходство (от -1 до 1)
   */
  cosineSimilarity(embedding1, embedding2) {
    if (embedding1.length !== embedding2.length) {
      throw new Error('Embeddings должны иметь одинаковую длину');
    }

    let dotProduct = 0;
    let norm1 = 0;
    let norm2 = 0;

    for (let i = 0; i < embedding1.length; i++) {
      dotProduct += embedding1[i] * embedding2[i];
      norm1 += embedding1[i] * embedding1[i];
      norm2 += embedding2[i] * embedding2[i];
    }

    return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
  }

  /**
   * Освобождение ресурсов
   */
  async dispose() {
    if (this.session) {
      await this.session.release();
      this.session = null;
      console.log('ONNX сессия освобождена');
    }
  }
}

module.exports = OnnxFaceEmbeddingService;
  • yandex-disk-service.js - сервис который получает файлы с Yandex Disk - тут все просто - базовые методы: получить список файлов, скачать файл

// yandex-disk-service.js
const axios = require('axios');

class YandexDiskService {
    constructor(oauthToken) {
        this.oauthToken = oauthToken;
        this.diskBaseURL = 'https://cloud-api.yandex.net/v1/disk';
    }

    /**
     * Получение списка файлов с Яндекс.Диска
     * @param {string} path - Путь к папке на Яндекс.Диске
     * @param {number} limit - Максимальное количество файлов
     * @returns {Promise<Array>} - Список файлов
     */
    async getFilesList(path = '/', limit = 1000) {
        try {
            const response = await axios.get(`${this.diskBaseURL}/resources`, {
                headers: { 'Authorization': `OAuth ${this.oauthToken}` },
                params: {
                    path: path,
                    limit: limit,
                    media_type: 'image'
                }
            });
            return response.data._embedded.items;
        } catch (error) {
            console.error('❌ Ошибка получения списка файлов:', error.response?.data || error.message);
            return [];
        }
    }

    /**
     * Скачивание файла с Яндекс.Диска
     * @param {string} filePath - Путь к файлу на Яндекс.Диске
     * @returns {Promise<Object|null>} - Объект с buffer, contentType и size или null при ошибке
     */
    async downloadFile(filePath) {
        try {
            // Получаем ссылку для скачивания
            const response = await axios.get(`${this.diskBaseURL}/resources/download`, {
                headers: { 'Authorization': `OAuth ${this.oauthToken}` },
                params: { path: filePath }
            });

            // Скачиваем файл по полученной ссылке
            const downloadResponse = await axios.get(response.data.href, {
                responseType: 'arraybuffer'
            });

            return {
                buffer: downloadResponse.data,
                contentType: downloadResponse.headers['content-type'],
                size: downloadResponse.data.length
            };
        } catch (error) {
            console.error('❌ Ошибка скачивания файла:', error.response?.data || error.message);
            return null;
        }
    }

    /**
     * Копирование файла на Яндекс.Диске
     * @param {string} sourcePath - Путь к исходному файлу
     * @param {string} targetPath - Путь к целевому файлу
     * @param {boolean} overwrite - Перезаписывать ли существующий файл (по умолчанию false)
     * @returns {Promise<boolean>} - true если файл скопирован, false если файл уже существует
     */
    async copyFile(sourcePath, targetPath, overwrite = false) {
        try {
            await axios.post(
                `${this.diskBaseURL}/resources/copy`,
                null,
                {
                    headers: { 'Authorization': `OAuth ${this.oauthToken}` },
                    params: {
                        from: sourcePath,
                        path: targetPath,
                        overwrite: overwrite
                    }
                }
            );
            return true;
        } catch (error) {
            if (error.response?.status === 409) {
                // Файл уже существует
                return false;
            }
            throw error;
        }
    }

    /**
     * Создание папки на Яндекс.Диске
     * @param {string} folderPath - Путь к создаваемой папке
     * @returns {Promise<boolean>} - true если папка создана или уже существует
     */
    async createFolder(folderPath) {
        try {
            await axios.put(
                `${this.diskBaseURL}/resources`,
                null,
                {
                    headers: { 'Authorization': `OAuth ${this.oauthToken}` },
                    params: { path: folderPath }
                }
            );
            return true;
        } catch (error) {
            if (error.response?.status === 409) {
                // Папка уже существует
                return true;
            }
            throw error;
        }
    }
}

module.exports = YandexDiskService;
  • ydb-sevice.js - сервис который сохраняет вектора лиц в базе и ищет схожие лица (используя функцию Knn::CosineDistance - кстати, так как эта фича появилась в YDB только весной этого года, ИИ про нее еще не в курсе - тут пришлось писать код самому).

    Еще важный момент - я в своем случае создал serverless базу. Это ок для экспериментов, но при хоть какой-то нагрузке (условно 1 запрос в секунду) сервис начинает постоянно отвечать с ошибкой RESOURCE_EXHAUSTED. Пришлось добавить обработку этой ошибки и делать несколько запросов с задержкой. Если решите использовать этот пример для плюс-минус объемного фотобанка - то лучше создавайте dedicated YDB.

const { Driver, TypedValues, Types, IamAuthService, getSACredentialsFromJson, withRetries } = require('ydb-sdk');
const { v4: uuidv4 } = require('uuid');

class YDBService {
    constructor(connectionString, database, serviceAccountKeyFile) {
        this.connectionString = connectionString;
        this.database = database;
        this.serviceAccountKeyFile = serviceAccountKeyFile;
        this.driver = null;
    }

    async initialize() {
        try {
            this.driver = new Driver({
                endpoint: this.connectionString,
                database: this.database,
                authService: new IamAuthService(
                    getSACredentialsFromJson(this.serviceAccountKeyFile)
                )
            });

            await this.driver.ready(10000);
            console.log('✅ YDB driver initialized with IAM auth service');
            await this.createTables();
        } catch (error) {
            console.error('YDB initialization failed:', error);
            throw error;
        }
    }

    /**
     * Вспомогательная функция для задержки
     */
    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    /**
     * Выполнение запроса с повторами при RESOURCE_EXHAUSTED
     * @param {Function} queryFn - Функция выполнения запроса
     * @param {number} maxRetries - Максимальное количество повторов
     */
    async queryWithRetry(queryFn, maxRetries = 5) {
        let lastError;
        for (let attempt = 0; attempt < maxRetries; attempt++) {
            try {
                return await queryFn();
            } catch (error) {
                // Проверяем код ошибки или сообщение
                const isResourceExhausted =
                    error.code === 'RESOURCE_EXHAUSTED' ||
                    error.message?.includes('RESOURCE_EXHAUSTED') ||
                    error.message?.includes('OVERLOADED');

                if (!isResourceExhausted) {
                    throw error;
                }

                lastError = error;
                const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
                console.log(`   ⚠️  RESOURCE_EXHAUSTED: повтор ${attempt + 1}/${maxRetries} через ${delay}ms...`);
                await this.sleep(delay);
            }
        }
        throw lastError;
    }

    async createTables() {
        const tables = [
            {
                name: 'faces',
                query: `
                    CREATE TABLE faces (
                        face_id Serial,
                        person_id int32,
                        image_path Utf8,
                        embedding String,
                        confidence Double,
                        bbox Utf8,
                        similar_face_id Int32,
                        created_at Timestamp,
                        PRIMARY KEY (face_id)
                    );
                `
            },
            {
                name: 'persons',
                query: `
                    CREATE TABLE persons (
                        person_id Serial,
                        name Utf8,
                        custom_name Utf8,
                        PRIMARY KEY (person_id)
                    );
                `
            }
        ];

        for (const table of tables) {
            try {
                await this.driver.queryClient.do({
                      fn: async (session) => {
                        await session.execute({
                          text: table.query
                        });
                      },
                    });
                console.log(`✅ Table '${table.name}' created/verified`);
            } catch (error) {
                // Игнорируем ошибку "table already exists"
                if (error.message?.includes('already exists') || error.message?.includes('ALREADY_EXISTS') || error.message?.includes('path exist')) {
                    console.log(`ℹ️  Table '${table.name}' already exists`);
                } else {
                    console.error(`❌ Error creating table '${table.name}':`, error.message);
                }
            }
        }
    }

    // Сохранение информации о лице
    async saveFace(faceData) {
        const query = `
            DECLARE $person_id AS int32;
            DECLARE $image_path AS Utf8;
            DECLARE $embedding AS List<Float>;
            DECLARE $confidence AS Double;
            DECLARE $bbox AS Utf8;
            DECLARE $similar_face_id AS int32;

            UPSERT INTO faces (person_id, image_path, embedding, confidence, bbox, created_at, similar_face_id)
            VALUES ($person_id, $image_path, Untag(Knn::ToBinaryStringFloat($embedding), "FloatVector"), $confidence, $bbox, CurrentUtcTimestamp(), $similar_face_id);
        `;

        await this.queryWithRetry(async () => {
            await this.driver.tableClient.withSession(async (session) => {
                await session.executeQuery(query,
                    {
                        $person_id: TypedValues.int32(faceData.person_id),
                        $image_path: TypedValues.utf8(faceData.image_path),
                        $embedding: TypedValues.list(Types.FLOAT, faceData.embedding),
                        $confidence: TypedValues.double(faceData.confidence),
                        $bbox: TypedValues.utf8(JSON.stringify(faceData.bbox)),
                        $similar_face_id: faceData.similar_face_id ? TypedValues.int32(faceData.similar_face_id) : TypedValues.optional(Types.INT32, null)
                    }
                );
            });
        });
    }

    // Сохранение/обновление персоны
    async savePerson(personData) {
        const query = `
            DECLARE $name AS Utf8;
            DECLARE $custom_name AS Utf8;

            INSERT INTO persons (name, custom_name)
            VALUES ($name, $custom_name)
            RETURNING person_id;
        `;

        let personId;
        await this.queryWithRetry(async () => {
            await this.driver.tableClient.withSession(async (session) => {
                const result = await session.executeQuery(query,
                    {
                        $name: TypedValues.utf8(personData.name || ''),
                        $custom_name: TypedValues.utf8(personData.custom_name || '')
                    }
                );

                // Получаем person_id из результата
                if (result.resultSets[0].rows && result.resultSets[0].rows.length > 0) {
                    personId = result.resultSets[0].rows[0].items[0].int32Value;
                }
            });
        });

        return personId;
    }

    // Объединение двух персон
    async joinPersons(personId1, personId2) {
        const updateQuery = `
            DECLARE $person_id1 AS Int32;
            DECLARE $person_id2 AS Int32;

            UPDATE faces
            SET person_id = $person_id1
            WHERE person_id = $person_id2;
        `;

        const deleteQuery = `
            DECLARE $person_id2 AS Int32;

            DELETE FROM persons
            WHERE person_id = $person_id2;
        `;

        await this.driver.tableClient.withSession(async (session) => {
            // Обновляем все лица с person_id2 на person_id1
            await session.executeQuery(updateQuery,
                {
                    $person_id1: TypedValues.int32(personId1),
                    $person_id2: TypedValues.int32(personId2)
                }
            );

            // Удаляем персону с person_id2
            await session.executeQuery(deleteQuery,
                {
                    $person_id2: TypedValues.int32(personId2)
                }
            );
        });

        console.log(`✅ Персоны объединены: person_id ${personId2} -> ${personId1}, персона ${personId2} удалена`);
    }

    /* Поиск похожих лиц
    * threshold - степень схожести. Важно - тут передается % схожести (чем выше, тем лучше) а Knn::CosineDistance наоборот возвращает разницу векторов (то есть чем меньше - тем лучше).
    * Поэтому мы вычитаем значение из 1 для конвертации
    */
    async findSimilarFaces(embedding, threshold = 0.8, nResults) {
        // convert threshold to YDB value
        const ratio = 1 - threshold;

        const query = `DECLARE $vector AS List<Float>;
              DECLARE $ratio as Float;
              $TargetEmbedding = Knn::ToBinaryStringFloat($vector);

              SELECT face_id, person_id, image_path, Knn::CosineDistance(embedding, $TargetEmbedding) as ratio
              FROM faces
              WHERE Knn::CosineDistance(embedding, $TargetEmbedding) < $ratio
              ORDER BY Knn::CosineDistance(embedding, $TargetEmbedding)
              LIMIT ${nResults};`;

        const params = {
          $vector: TypedValues.list(Types.FLOAT, embedding),
          $ratio: TypedValues.float(ratio)
        };

        const result = await this.queryWithRetry(async () => {
            return await this.driver.tableClient.withSession(async function(session) {
                return await session.executeQuery(query, params);
            });
        });

        return result.resultSets[0].rows.map(function(row) {
          return {
            face_id: row.items[0].int32Value,
            person_id: row.items[1].int32Value,
            image_path: row.items[2].textValue,
            ratio: 1 - row.items[3].floatValue   // convert ratio from YDB format
          };
        });
    }

    // Получение всех фото для человека
    async getPersonPhotos(personId) {
        const query = `
            SELECT image_path, confidence, created_at
            FROM faces
            WHERE person_id = $person_id
            ORDER BY confidence DESC;
        `;

        let result = [];
        await this.driver.tableClient.withSession(async (session) => {
            const dbResult = await session.executeQuery({
                query: query,
                parameters: {
                    $person_id: { textValue: personId }
                }
            });

            if (dbResult.resultSets[0].rows) {
                result = dbResult.resultSets[0].rows.map(row => ({
                    image_path: row.items[0].textValue,
                    confidence: row.items[1].doubleValue,
                    created_at: row.items[2].timestampValue
                }));
            }
        });

        return result;
    }

    // Получение уникальных путей к изображениям для персоны
    async getFacesByPersonId(personId) {
        const query = `
            DECLARE $person_id AS Int32;

            SELECT DISTINCT image_path
            FROM faces
            WHERE person_id = $person_id;
        `;

        let result = [];
        await this.queryWithRetry(async () => {
            await this.driver.tableClient.withSession(async (session) => {
                const dbResult = await session.executeQuery(query,
                    {
                        $person_id: TypedValues.int32(personId)
                    }
                );

                if (dbResult.resultSets[0].rows) {
                    result = dbResult.resultSets[0].rows.map(row => row.items[0].textValue);
                }
            });
        });

        return result;
    }

    // Получение всех лиц для заданного пути к изображению
    async findFacesByImagePath(imagePath) {
        const query = `
            DECLARE $image_path AS Utf8;

            SELECT face_id, person_id, confidence, bbox, similar_face_id, created_at
            FROM faces
            WHERE image_path = $image_path;
        `;

        let result = [];
        await this.queryWithRetry(async () => {
            await this.driver.tableClient.withSession(async (session) => {
                const dbResult = await session.executeQuery(query,
                    {
                        $image_path: TypedValues.utf8(imagePath)
                    }
                );

                if (dbResult.resultSets[0].rows) {
                    result = dbResult.resultSets[0].rows.map(row => ({
                        face_id: row.items[0].int32Value,
                        person_id: row.items[1].int32Value,
                        confidence: row.items[2].doubleValue,
                        bbox: row.items[3].textValue,
                        similar_face_id: row.items[4].int32Value || null,
                        created_at: row.items[5].timestampValue
                    }));
                }
            });
        });

        return result;
    }

    // Получение статистики
    async getStats() {
        const totalFacesQuery = `SELECT COUNT(*) as total_faces FROM faces;`;
        const totalPersonsQuery = `SELECT COUNT(DISTINCT person_id) as total_persons FROM faces;`;

        let stats = { total_faces: 0, total_persons: 0 };

        await this.driver.tableClient.withSession(async (session) => {
            // Получаем общее количество лиц
            const facesResult = await session.executeQuery(totalFacesQuery);
            if (facesResult.resultSets[0].rows && facesResult.resultSets[0].rows.length > 0) {
                stats.total_faces = facesResult.resultSets[0].rows[0].items[0].uint64Value || 0;
            }

            // Получаем количество уникальных людей
            const personsResult = await session.executeQuery(totalPersonsQuery);
            if (personsResult.resultSets[0].rows && personsResult.resultSets[0].rows.length > 0) {
                stats.total_persons = personsResult.resultSets[0].rows[0].items[0].uint64Value || 0;
            }
        });

        return stats;
    }

    // Закрытие соединения
    async destroy() {
        if (this.driver) {
            await this.driver.destroy();
        }
    }
}

module.exports = YDBService;
  • face-service.js - собственно класс, который берет папку на ЯД, ищет на фото в папке лица используя предыдущие классы и сохраняет информацию в YDB.

    Тут интересным параметром является SIMILAR_RATE - определяет пороговое значение, при котором лица считаются принадлежащим одному человеку. Чем меньше оно - тем больше шанс что разные лица будут назначены на одного человека. Чем выше - тем чаще система будет "не узнавать" человека и создавать новую персону. Но, если что, есть функция YDBService.joinPersons которая объединяет персоны.

// face-service.js
const YandexVisionFaceDetector = require('./yandex-vision-face-detector');
const OnnxFaceEmbeddingService = require('./onnx-face-embedding-service');
const YDBService = require('./ydb-service');
const YandexDiskService = require('./yandex-disk-service');
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

class FaceService {


    constructor(visionApiKey, visionFolderId,
                ydbConnectionString, ydbDatabase, yServiceAccountKeyFile,
                yandexDiskOauthToken) {
        this.TEST_DIR = './testfiles';
        this.SIMILAR_RATE = 0.35;

        this.visionDetector = new YandexVisionFaceDetector(visionApiKey, visionFolderId);
        this.embeddingService = new OnnxFaceEmbeddingService();
        this.ydbService = new YDBService(
                                 ydbConnectionString,
                                 ydbDatabase,
                                 yServiceAccountKeyFile
                             );
        this.yandexDiskService = new YandexDiskService(yandexDiskOauthToken);

        this.isInitialized = false;

        // Настройки оптимизации изображения
        this.optimizationConfig = {
            maxFileSize: 4 * 1024 * 1024, // 4MB
            maxDimension: 2048,
            defaultQuality: 95,
            minQuality: 40
        };
    }

    /**
     * Инициализация сервиса
     */
    async initialize(modelsPath = './models') {
        try {
            await this.embeddingService.initialize(modelsPath);
            await this.ydbService.initialize();

            this.isInitialized = true;
            console.log('✅ FaceService инициализирован');
        } catch (error) {
            console.error('❌ Ошибка инициализации FaceService:', error);
            throw error;
        }
    }

    /**
     * Оптимизация изображения для обработки
     */
    async optimizeImage(imageBuffer) {
        const originalSize = imageBuffer.length;
        const originalSizeMB = (originalSize / 1024 / 1024).toFixed(2);

        console.log(`? Размер оригинального изображения: ${originalSizeMB} MB`);

        // Если изображение уже меньше лимита, возвращаем как есть
        if (originalSize <= this.optimizationConfig.maxFileSize) {
            console.log('✅ Изображение уже подходит по размеру');
            return imageBuffer;
        }

        console.log('? Оптимизируем изображение...');

        // Получаем метаданные изображения
        const metadata = await sharp(imageBuffer).metadata();
        console.log(`   Исходные размеры: ${metadata.width}x${metadata.height}`);
        console.log(`   Формат: ${metadata.format}`);

        let optimizedBuffer = imageBuffer;
        let quality = this.optimizationConfig.defaultQuality;
        let currentSize = originalSize;
        let attempts = 0;
        const maxAttempts = 5;

        // Сначала уменьшаем размеры если нужно
        if (metadata.width > this.optimizationConfig.maxDimension ||
            metadata.height > this.optimizationConfig.maxDimension) {

            console.log('   Уменьшаем размеры изображения...');
            optimizedBuffer = await sharp(imageBuffer)
                .resize(
                    this.optimizationConfig.maxDimension,
                    this.optimizationConfig.maxDimension,
                    {
                        fit: 'inside',
                        withoutEnlargement: true
                    }
                )
                .jpeg({ quality: quality })
                .toBuffer();

            currentSize = optimizedBuffer.length;
            console.log(`   Размер после ресайза: ${(currentSize / 1024 / 1024).toFixed(2)} MB`);
        }

        // Если все еще больше лимита, уменьшаем качество
        while (currentSize > this.optimizationConfig.maxFileSize && attempts < maxAttempts) {
            attempts++;
            quality -= 15;

            if (quality < this.optimizationConfig.minQuality) {
                quality = this.optimizationConfig.minQuality;
            }

            console.log(`   Попытка ${attempts}: качество ${quality}%`);

            optimizedBuffer = await sharp(optimizedBuffer)
                .jpeg({
                    quality: quality,
                    mozjpeg: true
                })
                .toBuffer();

            currentSize = optimizedBuffer.length;
            console.log(`   Размер: ${(currentSize / 1024 / 1024).toFixed(2)} MB`);

            if (currentSize <= this.optimizationConfig.maxFileSize) {
                break;
            }
        }

        // Если все еще не влезает, используем более агрессивное сжатие
        if (currentSize > this.optimizationConfig.maxFileSize) {
            console.log('   Применяем агрессивное сжатие...');
            optimizedBuffer = await sharp(imageBuffer)
                .resize(1024, 1024, {
                    fit: 'inside',
                    withoutEnlargement: true
                })
                .jpeg({
                    quality: 60,
                    chromaSubsampling: '4:2:0'
                })
                .toBuffer();

            currentSize = optimizedBuffer.length;
            console.log(`   Финальный размер: ${(currentSize / 1024 / 1024).toFixed(2)} MB`);
        }

        const compressionRatio = ((originalSize - currentSize) / originalSize * 100).toFixed(1);
        console.log(`✅ Оптимизация завершена: сжатие ${compressionRatio}%`);

        return optimizedBuffer;
    }

    /**
     * Получение информации об изображении
     */
    async getImageInfo(imageBuffer) {
        const metadata = await sharp(imageBuffer).metadata();
        return {
            format: metadata.format,
            width: metadata.width,
            height: metadata.height,
            size: imageBuffer.length,
            sizeMB: (imageBuffer.length / 1024 / 1024).toFixed(2)
        };
    }

    /**
     * Создание папки test если не существует
     */
    ensureTestDir() {
        const testDir = this.TEST_DIR;
        if (!fs.existsSync(testDir)) {
            fs.mkdirSync(testDir, { recursive: true });
        }
        return testDir;
    }

    /**
     * Сохранение вырезанного лица в файл
     */
    async saveFaceImage(faceBuffer, index, timestamp) {
        const testDir = this.ensureTestDir();
        const faceFilename = path.join(testDir, `face_${timestamp}_${index + 1}.jpg`);

        await sharp(faceBuffer)
            .jpeg({ quality: 90 })
            .toFile(faceFilename);

        console.log(`? Сохранено лицо: ${faceFilename}`);
        return faceFilename;
    }

    /**
     * Вырезание и сохранение лиц (между детекцией и эмбеддингами)
     */
    async extractAndSaveFaces(imageBuffer, faceDetections) {
        const timestamp = Date.now();
        const faceBuffers = [];

        for (let i = 0; i < faceDetections.length; i++) {
            const detection = faceDetections[i];
            try {
                // Вырезаем область лица
                const faceBuffer = await this.embeddingService.extractFaceRegion(imageBuffer, detection.bbox);

                // Сохраняем лицо в файл
                await this.saveFaceImage(faceBuffer, i, timestamp);

                faceBuffers.push({
                    buffer: faceBuffer,
                    detection: detection
                });

            } catch (error) {
                console.error(`❌ Ошибка сохранения лица ${i + 1}:`, error.message);
            }
        }

        return faceBuffers;
    }

    /**
     * Полная обработка: оптимизация -> детекция -> сохранение -> эмбеддинги
     */
    async processFaces(imageBuffer, saveFaces = true) {
        if (!this.isInitialized) {
            throw new Error('❌ Сервис не инициализирован. Вызовите initialize() сначала.');
        }

        try {
            // Шаг 1: Оптимизация изображения
            console.log('? Оптимизация изображения...');
            const optimizedImageBuffer = await this.optimizeImage(imageBuffer);

            const imageInfo = await this.getImageInfo(optimizedImageBuffer);
            console.log(`✅ Оптимизированное изображение: ${imageInfo.width}x${imageInfo.height}, ${imageInfo.sizeMB} MB`);

            // Шаг 2: Детекция лиц
            console.log('\n? Детекция лиц через Yandex Vision...');
            const faceDetections = await this.visionDetector.detectFaces(optimizedImageBuffer);

            console.log(`✅ Найдено лиц: ${faceDetections.length}`);

            if (faceDetections.length === 0) {
                return [];
            }

            // Шаг 3: Сохранение лиц
            let faceBuffers = [];
            if (saveFaces) {
                console.log('\n? Сохранение найденных лиц...');
                faceBuffers = await this.extractAndSaveFaces(optimizedImageBuffer, faceDetections);
                console.log(`✅ Сохранено лиц: ${faceBuffers.length}`);
            }

            // Шаг 4: Получение эмбеддингов
            console.log('\n? Получение эмбеддингов ...');
            const results = [];

            for (let i = 0; i < faceDetections.length; i++) {
                const detection = faceDetections[i];
                try {
                    // Используем уже вырезанный буфер если сохраняли, иначе вырезаем заново
                    const faceBuffer = saveFaces
                        ? faceBuffers[i].buffer
                        : await this.embeddingService.extractFaceRegion(optimizedImageBuffer, detection.bbox);

                    const embedding = await this.embeddingService.getFaceEmbedding(faceBuffer);

                    results.push({
                        bbox: detection.bbox,
                        confidence: detection.confidence,
                        embedding: embedding,
                        faceImage: faceBuffer,
                        landmarks: detection.landmarks,
                        attributes: detection.attributes
                    });

                    console.log(`✅ Получен эмбеддинг для лица ${i + 1}`);

                } catch (error) {
                    console.error(`❌ Ошибка получения эмбеддинга для лица ${i + 1}:`, error.message);
                }
            }

            console.log(`? Получено эмбеддингов: ${results.length}`);
            return results;

        } catch (error) {
            console.error('❌ Ошибка в processFaces:', error);
            throw error;
        }
    }

    /**
     * Только детекция лиц (с оптимизацией)
     */
    async detectFacesOnly(imageBuffer) {
        if (!this.isInitialized) {
            throw new Error('❌ Сервис не инициализирован');
        }

        console.log('? Оптимизация изображения для детекции...');
        const optimizedImageBuffer = await this.optimizeImage(imageBuffer);

        return await this.visionDetector.detectFaces(optimizedImageBuffer);
    }

    /**
     * Только получение эмбеддингов для уже обнаруженных лиц (с оптимизацией)
     */
    async getEmbeddingsOnly(imageBuffer, faceDetections, saveFaces = true) {
        if (!this.isInitialized) {
            throw new Error('❌ Сервис не инициализирован');
        }

        console.log('? Оптимизация изображения для эмбеддингов...');
        const optimizedImageBuffer = await this.optimizeImage(imageBuffer);

        let faceBuffers = [];
        /*
        if (saveFaces) {
            faceBuffers = await this.extractAndSaveFaces(optimizedImageBuffer, faceDetections);
        }
        */

        const results = [];
        for (let i = 0; i < faceDetections.length; i++) {
            const detection = faceDetections[i];
            try {
                const faceBuffer = saveFaces
                    ? faceBuffers[i].buffer
                    : await this.embeddingService.extractFaceRegion(optimizedImageBuffer, detection.bbox);

                const embedding = await this.embeddingService.getFaceEmbedding(faceBuffer);

                results.push({
                    bbox: detection.bbox,
                    confidence: detection.confidence,
                    embedding: embedding,
                    faceImage: faceBuffer
                });

            } catch (error) {
                console.error(`❌ Ошибка обработки лица ${i + 1}:`, error.message);
            }
        }

        return results;
    }

    /** Сохраняет данные о лице в YDB
    */
    async saveFace(imagePath, face) {
        // поиск похожего лица в базе
        const similarFaces = await this.ydbService.findSimilarFaces(face.embedding, this.SIMILAR_RATE, 1);

        let personId;
        let similarFaceId = null;

        if (similarFaces.length > 0) {
            // если лицо найдено - используем его person_id и сохраняем similar_face_id
            personId = similarFaces[0].person_id;
            similarFaceId = similarFaces[0].face_id;
            console.log(`   ? В Базе найдены похожие лица: ${similarFaces[0].image_path} (схожесть: ${(similarFaces[0].ratio * 100).toFixed(1)}%)`);
            console.log(`   ? Используем существующую персону: person_id=${personId}, similar_face_id=${similarFaceId}`);
        } else {
            // если не найдено - создаем новую персону
            console.log(`   ℹ️  В Базе не найдены похожие лица`);
            personId = await this.ydbService.savePerson({
                name: 'Face from ' + imagePath,
                custom_name: 'Face from ' + imagePath
            });
            console.log(`   ✨ Создана новая персона: person_id=${personId}`);
        }

        await this.ydbService.saveFace({
            person_id: personId,
            image_path: imagePath,
            embedding: face.embedding,
            confidence: face.confidence,
            bbox: face.bbox,
            similar_face_id: similarFaceId
        });
    }

    /**
     * Обработка одного файла с Яндекс.Диска
     * @param {Object} file - Объект файла с Яндекс.Диска
     * @returns {Promise<Object|null>} - Результат обработки или null при ошибке
     */
    async processDiskFile(file) {
        try {
            console.log(`\n   ? Обработка файла: ${file.name}`);

            // Проверяем, есть ли уже лица для этого изображения в базе
            console.log(`   ? Проверка наличия в базе...`);
            const existingFaces = await this.ydbService.findFacesByImagePath(file.path);

            if (existingFaces.length > 0) {
                console.log(`   ⏭️  Файл уже обработан (найдено ${existingFaces.length} лиц в базе)`);
                return {
                    file: file.name,
                    facesCount: 0,
                    skipped: true
                };
            }

            // Скачиваем файл
            console.log(`   ? Скачивание...`);
            const fileData = await this.yandexDiskService.downloadFile(file.path);

            if (!fileData) {
                console.log(`   ❌ Не удалось скачать файл`);
                return null;
            }

            console.log(`   ✅ Файл скачан: ${(fileData.size / 1024).toFixed(2)} KB`);

            // Обрабатываем лица в изображении
            const faces = await this.processFaces(fileData.buffer, false);

            if (faces.length === 0) {
                console.log(`   ℹ️  Лица не найдены`);
                return { file: file.name, facesCount: 0 };
            }

            console.log(`   ? Найдено лиц: ${faces.length}`);

            // Сохраняем каждое найденное лицо
            for (let i = 0; i < faces.length; i++) {
                console.log(`   ? Сохранение лица ${i + 1}/${faces.length}...`);
                await this.saveFace(file.path, faces[i]);
            }

            console.log(`   ✅ Обработка завершена: ${faces.length} лиц сохранено`);

            return {
                file: file.name,
                facesCount: faces.length
            };

        } catch (error) {
            console.error(`   ❌ Ошибка обработки файла ${file.name}:`, error.message);
            return null;
        }
    }

    /**
     * Обработка папки на Яндекс.Диске
     * @param {string} folderPath - Путь к папке на Яндекс.Диске
     * @param {number} limit - Максимальное количество файлов для обработки
     * @returns {Promise<Object>} - Статистика обработки
     */
    async processDiskFolder(folderPath, limit = 100) {
        if (!this.isInitialized) {
            throw new Error('❌ Сервис не инициализирован. Вызовите initialize() сначала.');
        }

        const startTime = Date.now();

        console.log('? ОБРАБОТКА ПАПКИ НА ЯНДЕКС.ДИСКЕ');
        console.log(`   Путь: ${folderPath}`);
        console.log(`   Лимит файлов: ${limit}\n`);

        try {
            // Получаем список файлов
            console.log('? Получение списка файлов...');
            const files = await this.yandexDiskService.getFilesList(folderPath, limit);

            if (files.length === 0) {
                console.log('❌ Файлы не найдены');
                return { totalFiles: 0, processedFiles: 0, totalFaces: 0, duration: 0 };
            }

            console.log(`✅ Найдено файлов: ${files.length}\n`);

            const stats = {
                totalFiles: files.length,
                processedFiles: 0,
                skippedFiles: 0,
                totalFaces: 0,
                errors: 0
            };

            // Обрабатываем каждый файл
            for (let i = 0; i < files.length; i++) {
                const file = files[i];

                console.log(`\n${'='.repeat(60)}`);
                console.log(`? Файл ${i + 1}/${files.length}: ${file.name}`);
                console.log('='.repeat(60));

                const result = await this.processDiskFile(file);

                if (result) {
                    if (result.skipped) {
                        stats.skippedFiles++;
                    } else {
                        stats.processedFiles++;
                        stats.totalFaces += result.facesCount;
                    }
                } else {
                    stats.errors++;
                }
            }

            // Вычисляем время работы
            const endTime = Date.now();
            const durationMs = endTime - startTime;
            const durationSec = (durationMs / 1000).toFixed(2);
            const durationMin = (durationMs / 60000).toFixed(2);

            stats.durationMs = durationMs;
            stats.durationSec = durationSec;
            stats.durationMin = durationMin;

            // Выводим итоговую статистику
            console.log('\n' + '═'.repeat(60));
            console.log('? ИТОГОВАЯ СТАТИСТИКА');
            console.log('═'.repeat(60));
            console.log(`   Всего файлов:        ${stats.totalFiles}`);
            console.log(`   Обработано:          ${stats.processedFiles}`);
            console.log(`   Пропущено:           ${stats.skippedFiles}`);
            console.log(`   Ошибок:              ${stats.errors}`);
            console.log(`   Всего найдено лиц:   ${stats.totalFaces}`);
            console.log(`   Время работы:        ${durationMin} мин (${durationSec} сек)`);
            console.log('═'.repeat(60));

            return stats;

        } catch (error) {
            console.error('❌ Ошибка обработки папки:', error.message);
            throw error;
        }
    }
}

module.exports = FaceService;
  • process-disk-folder.js - функция main которая это все запускает.

// process-disk-folder.js
require('dotenv').config();
const FaceService = require('./face-service');

async function main() {
    // Проверяем аргументы командной строки
    if (process.argv.length < 3) {
        console.log('❌ Использование: node process-disk-folder.js <folder_path> [limit]');
        console.log('   Пример: node process-disk-folder.js /testphoto 50');
        console.log('   Пример: node process-disk-folder.js /photos (обрабатывает все файлы)');
        console.log('');
        console.log('   folder_path - путь к папке на Яндекс.Диске');
        console.log('   limit - максимальное количество файлов для обработки (по умолчанию: все файлы)');
        process.exit(1);
    }

    const folderPath = process.argv[2];
    const limit = process.argv[3] ? parseInt(process.argv[3]) : 10000;

    // Валидация лимита
    if (process.argv[3] && (isNaN(limit) || limit <= 0)) {
        console.error('❌ Ошибка: limit должен быть положительным числом');
        process.exit(1);
    }

    console.log('? ОБРАБОТКА ПАПКИ С ЯНДЕКС.ДИСКА\n');
    console.log(`   Папка: ${folderPath}`);
    if (process.argv[3]) {
        console.log(`   Лимит: ${limit} файлов\n`);
    } else {
        console.log(`   Лимит: все файлы\n`);
    }

    // Валидация обязательных переменных окружения
    const requiredEnvVars = [
        'YANDEX_OAUTH_TOKEN',
        'YANDEX_CLOUD_API_KEY',
        'SERVICE_ACCOUNT_KEY_FILE',
        'YANDEX_FOLDER_ID',
        'YDB_CONNECTION_STRING',
        'YDB_DATABASE'
    ];

    const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
    if (missingVars.length > 0) {
        console.error('❌ Отсутствуют обязательные переменные окружения:');
        missingVars.forEach(varName => console.error(`   - ${varName}`));
        console.error('\nПожалуйста, проверьте файл .env');
        process.exit(1);
    }

    // Инициализация FaceService
    const faceService = new FaceService(
        process.env.YANDEX_CLOUD_API_KEY,
        process.env.YANDEX_FOLDER_ID,
        process.env.YDB_CONNECTION_STRING,
        process.env.YDB_DATABASE,
        process.env.SERVICE_ACCOUNT_KEY_FILE,
        process.env.YANDEX_OAUTH_TOKEN
    );

    try {
        console.log('? Инициализация сервисов...');
        await faceService.initialize();
        console.log('✅ Сервисы инициализированы\n');

        // Обрабатываем папку
        const stats = await faceService.processDiskFolder(folderPath, limit);

        console.log('\n✅ Обработка завершена!');
        console.log(`   Обработано файлов: ${stats.processedFiles}/${stats.totalFiles}`);
        console.log(`   Пропущено файлов: ${stats.skippedFiles}`);
        console.log(`   Найдено лиц: ${stats.totalFaces}`);
        console.log(`   Время работы: ${stats.durationMin} мин (${stats.durationSec} сек)`);

        if (stats.errors > 0) {
            console.log(`   ⚠️  Ошибок: ${stats.errors}`);
        }

    } catch (error) {
        console.error('\n❌ Ошибка при обработке:', error.message);
        console.error(error);
        process.exit(1);
    } finally {
        await faceService.ydbService.destroy();
    }
}

// Обработка graceful shutdown
process.on('SIGINT', async () => {
    console.log('\n? Прерывание работы...');
    process.exit(0);
});

process.on('SIGTERM', async () => {
    console.log('\n? Получен сигнал завершения...');
    process.exit(0);
});

main().catch(console.error);

Если вдруг кто-то решит воспользоваться надо выполнить следующие шаги:

  • Скачать модель insightfaces.onnx в папку ./models

  • Создать .env. c необходимыми переменными среды

# Яндекс OAuth токен для доступа к Диску
YANDEX_OAUTH_TOKEN=

# Путь к файлу с приватным ключом сервисного аккаунта - для работы с YDB
SERVICE_ACCOUNT_KEY_FILE=./key.json


# API Ключ для работы с VISION
YANDEX_CLOUD_API_KEY=

# Service Account ID
SERVICE_ACCOUNT_ID=

# Folder ID в Yandex Cloud
YANDEX_FOLDER_ID=

# YDB настройки
YDB_CONNECTION_STRING=grpcs://ydb.serverless.yandexcloud.net:2135
YDB_DATABASE=
  • Вызвать node process-disk-folder.js /SomeFolder/On/YandexDisk - и запастись попкорном.

Кстати, если вдруг для данной задачи есть какое-то более простое решение (AWS Rekognition не предлагать) - буду благодарен комментариям!

Комментарии (7)


  1. positroid
    23.10.2025 10:49

    на Яндекс Диск, где его нет до сих пор

    Справедливости ради есть, но нашёл только в мобильном приложении на вкладке альбомов - само создаёт подборки с часто встречаемыми персонажами. Работает не идеально, пересечение по нескольким людям, как в посте, не посмотришь, но хоть что-то.


    1. akakunin Автор
      23.10.2025 10:49

      Нет, не нашел у себя такого :( Может еще зависит от версии (у меня Android)


      1. positroid
        23.10.2025 10:49

        Нашел на QA яндекса такую инфу: альбом создается сам и доступен по ссылке - https://disk.yandex.ru/client/albums/faces. Условия такие (на 2021 год):

        • Альбом лица создаётся, если на Диске не менее 10 фотографий одного и того же человека;

        • только для пользователей из России, Украины, Казахстана и Белоруссии.

        Судя по комментариям какая-то сырая фича не для всех


    1. JBFW
      23.10.2025 10:49

      То есть, Яндекс сканирует загружаемые фото на часто встречаемых персонажей.
      Ок, это интересная информация, буду ее думать (особенно в свете привязки к YandexID)


  1. freeExec
    23.10.2025 10:49

    Это новый тренд устраивать гитхаб из статьи?


    1. Olegun
      23.10.2025 10:49

      Автор не доверяет иностранным сервисам кроме хабра.


      1. akakunin Автор
        23.10.2025 10:49

        Не совсем. Под эти эксперименты я сделал репы в SourceCraft Яндекса (заодно и его тестирую). Но там есть нюансы с созданием открытых репозиториев - надо менять настройки всей организации, пока не стал на это заморачиваться. Кода не много, поэтому решил в статью сразу выложить