Skip to content

Repository files navigation

camRecorder

Java Swing-приложение для работы с RTSP/IP-камерами и архивом записей.

Структура проекта

src/
├── main/java/        # собственный Java-код приложения
├── main/kotlin/      # новая Kotlin-интеграция и пути данных приложения
├── main/resources/   # изображения, .form и META-INF
├── generated/java/   # существующий ONVIF/OASIS-код
└── test/             # тесты

ONVIF/OASIS-классы уже сгенерированы и хранятся отдельно; Gradle компилирует их как дополнительный source set вместе с кодом приложения.

FFmpeg

FFmpeg является внешней зависимостью приложения. Путь можно указать в настройках; если поле пустое, приложение автоматически проверяет:

  1. системный PATH;
  2. типовые каталоги macOS/Linux (/opt/homebrew/bin, /usr/local/bin, /usr/bin);
  3. bundled-каталоги рядом с приложением: ffmpeg/ffmpeg, ffmpeg/bin/ffmpeg, runtime/ffmpeg.

Каждый найденный кандидат проверяется командой ffmpeg -version. FFmpeg автоматически не скачивается и не устанавливается.

Запуск операций выполняется через Kotlin-слой и ProcessBuilder, поэтому пути с пробелами обрабатываются корректно. Для длительных операций установлены таймауты; временный файл удаляется только после успешного завершения FFmpeg.

Как формируется чанк до FFmpeg

До запуска FFmpeg приложение не пишет MP4. Для каждой камеры оно формирует временный сегмент в data/archive/temp/ и сохраняет в нём уже разобранные RTP данные:

  1. После подключения к RTSP создаётся запись ARCHIVE с идентификатором, камерой и временем начала сегмента.
  2. RTP-пакеты H.264 проходят через depacketizer. Обычный NAL записывается в формате Annex B с четырёхбайтовым start code 00 00 00 01.
  3. Фрагменты FU-A собираются последовательно: для первого фрагмента восстанавливаются исходный NAL-заголовок и start code, остальные фрагменты дописываются в тот же поток.
  4. SPS/PPS из SDP камеры добавляются в начало первого временного видеопотока, чтобы последующий muxer мог определить параметры H.264; в следующих чанках используются параметры, приходящие внутри RTP, если камера их передаёт.
  5. Если в SDP есть поддерживаемая аудиодорожка, рядом создаётся companion-файл: .audio.aac для AAC или .audio.pcm для PCMA/PCMU после преобразования в PCM16.
  6. RTP sequence, timestamp, SSRC и RTCP Sender Report используются для контроля порядка, потерь и общей временной шкалы. Сами raw-файлы не являются MP4 и не содержат таблиц контейнера; аудио-offset передаётся на следующем этапе.
  7. По истечении интервала записи текущие потоки закрываются, в SQLite записывается время окончания, создаётся новый сегмент, а закрытый чанк передаётся в FFmpeg.

Видеочанк обычно имеет имя <cameraId>_<date>_<archiveId> без расширения, пока находится в temp. Это намеренно: расширение .mp4 появляется только после успешной упаковки. При ошибке исходные временные файлы переносятся в quarantine, а не объявляются готовой записью.

Что делает FFmpeg

Приложение использует FFmpeg как внешний конвертер и упаковщик видеозаписей:

  • получает отдельные временные файлы видео и аудио текущего сегмента;
  • копирует H.264 в итоговый MP4 без перекодирования (-c:v copy);
  • для AAC MPEG4-GENERIC принимает ADTS-файл и формирует AAC-дорожку MP4;
  • для PCMA/PCMU FFmpeg получает PCM16 и кодирует звук в AAC;
  • при работе с архивом объединяет последовательность MP4-сегментов через concat demuxer;
  • удаляет временные видео- и аудиофайлы только после успешного завершения операции;
  • при наличии RTP/RTCP offset применяет его к аудиовходу через -itsoffset;
  • при восстановлении незавершённых файлов выбирает mux для пары video/audio или обычный video-only move.

Собственный RTSP/RTP-код приложения отвечает за получение потока и запись сегментов, а FFmpeg — за финальную упаковку и объединение файлов в архив.

Откуда берётся RTSP-ссылка

RTSP-ссылка камеры не зашита в приложении. При добавлении камеры через ONVIF приложение отправляет запрос GetStreamUri для выбранного профиля, а затем берёт URL из поля MediaUri.uri в ответе камеры. Например, камера Dahua может вернуть ссылку вида:

rtsp://192.168.1.13:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif

Путь /cam/realmonitor и параметры channel, subtype, unicast и proto являются форматом конкретной камеры. subtype=0 обычно обозначает основной поток, а subtype=1 — дополнительный поток. В коде запрос выполняется методом ru.xsrv.onvif.cam.Cam.getStreamUri(), а ответ обрабатывается в AddDialog1.loadProfile().

При сохранении приложения URL очищается от userinfo. Логин и пароль хранятся отдельно и передаются RTSP/VLC-слою независимо от URL.

Аудио RTSP-записей

Кодек определяется по SDP, а не по самому числу payload type. Поддерживаются:

  • AAC MPEG4-GENERIC — RTP AU преобразуются в ADTS;
  • G.711 A-law PCMA и μ-law PCMU — преобразуются в PCM16;
  • MP3 пока только распознаётся как неподдержанный отдельный RTP-формат.

Видео хранится во временном raw H.264-файле, аудио — в companion-файле с тем же именем и суффиксом .audio.aac или .audio.pcm. При mux приложение использует RTCP Sender Report для общей временной шкалы; если RTCP отсутствует, применяется fallback по времени получения первого RTP-пакета.

Для raw H.264 включена генерация PTS через FFmpeg +genpts. Полная проверка длинных записей и устранение всех предупреждений о временной шкале остаются отдельной задачей из AUDIO.md.

Почему вместо TreeMap используется slot-структура

В старом UDP-пути пакеты временно хранились в TreeMap<Integer, ...>: sequence number был ключом, а TreeMap автоматически держал ключи в порядке. Это удобно и понятно, но каждый пакет требует работы дерева, сравнений ключей, объектов Integer и записей map. Вставка и поиск имеют стоимость O(log n).

Теперь используется bounded slot/ring buffer RtpReorderBuffer:

sequence number  ->  slot в массиве фиксированного размера

Индекс вычисляется битовой операцией, поэтому вставка и извлечение обычно имеют стоимость O(1). В slot хранится sequence number и ссылка на RtpPayloadSegment. Буфер умеет обрабатывать перестановку, дубликаты, потерянные пакеты и переход 65535 -> 0, а его размер не растёт бесконечно.

Это не означает, что slot-структура всегда лучше: TreeMap проще, удобен для небольших наборов и автоматически сортирует произвольные ключи. Ring buffer выгоден именно здесь, потому что RTP sequence имеет диапазон 16 бит, порядок известен заранее, а скорость потока может достигать тысяч пакетов в секунду. Цена slot-структуры — необходимость самостоятельно описать wrap-around, duplicate, overflow и политику пропуска потерянных пакетов. Поэтому она сначала покрыта отдельными тестами и сравнивается с baseline, а не вводится только ради битовых операций.

OutputStream и bounded writer queue

OutputStream — это интерфейс для последовательной записи байтов. Сам по себе он не создаёт отдельный поток и не делает запись асинхронной. Если вызвать output.write(...) внутри цикла приёма RTP, сетевой поток будет ждать, пока диск, muxer или следующий слой примет данные. synchronized(output) добавляет защиту от одновременной записи, но не устраняет блокировку.

Схема прямой записи выглядит так:

receive RTP -> parse -> output.write -> следующий пакет

В UDP video-пути теперь используется ArchiveWriterQueue:

receive RTP -> parse -> bounded queue -> writer thread -> OutputStream

Приём RTP передаёт готовый RtpPayloadSegment через неблокирующий offer и может сразу принимать следующий пакет. Один worker сохраняет порядок сегментов и последовательно вызывает настоящий OutputStream.write.

Очередь ограничена по размеру. Это принципиально отличается от LinkedBlockingQueue без лимита: при медленном диске память не растёт бесконечно. Если очередь заполнена, новый сегмент отбрасывается, увеличивается счётчик droppedCount и пишется WARN. Это осознанный backpressure trade-off: лучше явно зафиксировать потерю RTP-сегмента, чем остановить чтение сокета и потерять ещё больше пакетов из-за переполнения сетевого буфера.

Обычный OutputStream всё ещё нужен как конечный слой записи; bounded queue не заменяет его формат или семантику, а разносит по времени приём пакета и запись на диск. Audio/interleaved-пути пока требуют отдельной проверки перед таким же переводом.

Статусы камер в интерфейсе

У каждой камеры в дереве отображается цветной бейдж текущего состояния:

  • зелёный — запись идёт;
  • оранжевый — камера подключается или переподключается;
  • красный ! — произошла ошибка;
  • серый — камера отключена или останавливается.

При наведении на камеру показывается всплывающая подсказка с состоянием, номером камеры, задержкой до следующей попытки и последней ошибкой, если она есть. Дерево остаётся визуально активным во время записи, чтобы бейджи обновлялись и были видны; управление запуском и остановкой выполняется кнопками панели.

Имена новых файлов записей имеют формат <cameraId>_<yyyyMMdd_HHmmss>_<archiveId>.mp4, например 81_20260808_123500_205.mp4. Дата берётся из времени начала записи и форматируется в UTC; archiveId сохраняет связь файла с записью в SQLite.

История удалённых камер

Удаление камеры выполняется мягко. Строка в CAMS не удаляется, потому что записи ARCHIVE ссылаются на её ID. Камера исключается из дерева и из запуска записи, но остаётся доступной в диалоге архива, если для неё есть исторические сегменты. В списке она помечается как [удалена].

При удалении очищаются USERNAME и PASSWORD, а PASSWORD_TYPE меняется на NONE. URL потока, ONVIF URL, профиль и ID сохраняются только для идентификации источника и отображения истории; повторное добавление камеры создаёт новую активную запись с новым ID.

Длительные операции объединения архива выполняются в фоне и показывают промежуточный progress bar, поэтому Swing-интерфейс не блокируется. Сейчас progress bar не показывает процент. Следующий шаг — подключить вывод FFmpeg через -progress и передавать в интерфейс фактические длительность, процент и оставшееся время.

Временные M3U-плейлисты для просмотра архива создаются в data/tmp, а не в корне запуска приложения.

В диалоге архива шкала над списком показывает интервалы выбранной камеры за выбранные сутки. Зелёный блок означает готовый MP4, серый — отсутствующий файл, красный — удалённый файл из истории. Шкалу можно масштабировать колесом или кнопками и сдвигать перетаскиванием; клик по блоку выделяет архив в списке.

При объединении архива создаются data/tmp/concat.mp4 и data/tmp/concat.srt. SRT содержит субтитры с реальной датой и временем исходной записи, которые обновляются каждые 10 секунд и подключаются к VLC автоматически.

Логирование

Приложение использует SLF4J + Logback. Логи выводятся в консоль и сохраняются в файле data/logs/camRecorder.log. Файлы ротируются по дате и размеру.

В dev-среде общий размер архивных логов ограничен 25MB. Для production его можно изменить без редактирования конфигурации:

java -DcamRecorder.log.totalSizeCap=500MB -jar camRecorder.jar

Обычный уровень логирования — INFO. Подробные RTSP-запросы, ответы и SQL доступны на уровне DEBUG.

При штатной остановке камеры прерывания фоновых потоков и закрытие RTSP socket считаются ожидаемыми событиями и не выводятся как ошибки. Неожиданные прерывания и сетевые ошибки получают WARN, а итоговый сбой камеры записывается одним ERROR со stack trace.

Данные приложения

Автоматическое управление размером архива, сроком хранения и свободным местом описано в STORAGE.md. Лимиты настраиваются в диалоге Settings; значение 0 отключает соответствующее ограничение. Очистка выполняется в фоне и удаляет только самые старые готовые MP4 из rec, не затрагивая активные файлы temp. При критическом заполнении диска создание новых сегментов блокируется, а текущий сегмент не прерывается.

Приложение хранит генерируемые данные в каталоге data/ относительно места запуска приложения:

data/
├── settings.dat
├── camRecorder.db
└── archive/
    ├── temp/
    └── rec/

При первом запуске старые файлы из корня проекта (settings.dat, test.db и стандартный archive/) автоматически переносятся в data/, если целевые файлы ещё не существуют. Нестандартный путь архива из пользовательских настроек не изменяется.

Версия схемы SQLite хранится внутри базы через PRAGMA user_version. При подключении выполняются необходимые миграции в транзакции; текущая схема — версия 4.

Для записей архива хранится признак FILE_DELETED. Он позволяет отличить подтверждённое удаление MP4 от временно отсутствующего файла.

Для камер сохраняются stream URL без userinfo, исходный ONVIF URL без userinfo, логин, пароль, тип пароля, способ добавления и имя профиля. На текущем этапе PASSWORD_TYPE=PLAIN, поэтому пароль хранится в SQLite открытым текстом. Это временная реализация до перехода на системное защищённое хранилище.

Как добавлять следующую миграцию SQLite

Каждое изменение схемы добавляется отдельным переходом N → N+1. Например, если нужно добавить индекс для нового поля, порядок действий такой:

  1. Увеличить CURRENT_VERSION в src/main/kotlin/ru/xsrv/recorder/model/SchemaMigrations.kt.
  2. Добавить ветку в последовательный when:
const val CURRENT_VERSION = 4

// внутри migrate()
when (appliedVersion) {
    3 -> {
        migrateVersionThreeToFour(connection)
        appliedVersion = 4
    }
}
  1. Реализовать отдельную миграцию с идемпотентным SQL:
private fun migrateVersionThreeToFour(connection: Connection) {
    connection.createStatement().use { statement ->
        statement.executeUpdate(
            "ALTER TABLE CAMS ADD COLUMN NEW_FIELD TEXT"
        )
    }
}
  1. Добавить тест для базы версии 3: проверить, что после миграции PRAGMA user_version равен 4, новое поле существует, а существующие данные сохранены.
  2. Добавить проверку повторного запуска миграции и отката при ошибке.

Не изменяйте старую миграцию и не переписывайте уже выпущенные версии: новая логика должна добавляться только как следующий переход. Все миграции выполняются одной транзакцией и применяются через локальный Gradle Wrapper.

Требования

  • macOS, Linux или Windows;
  • JDK 21;
  • доступ к интернету при первом скачивании Gradle-дистрибутива и зависимостей.

Системный Gradle устанавливать не нужно: проект использует локальный Gradle Wrapper.

Сборка

Из корня проекта:

./gradlew clean build

Первый запуск автоматически скачает Gradle 8.10.2 в локальную служебную директорию проекта и загрузит зависимости Maven. Результат сборки появится в build/.

Для создания запускаемого дистрибутива:

./gradlew installDist

После этого приложение можно запустить так:

./build/install/httpRecorder/bin/httpRecorder

Или запустить напрямую через Gradle:

./gradlew run

IntelliJ IDEA

Откройте проект как Gradle-проект, выбрав settings.gradle. Файлы GUI Designer .form сохраняются в проекте и обрабатываются Gradle-плагином при сборке.

Известные ограничения

  • При пустом списке камер ArchiveDialog может завершиться с ошибкой из-за отсутствия выбранной камеры.
  • Это отдельная ошибка приложения и не препятствует базовой Gradle-сборке.
  • Для production-запуска желательно отдельно добавить обработку пустого списка камер, тесты, логирование и конфигурацию подключения к камерам без хранения секретов в исходниках.

Полезные команды

./gradlew tasks       # список задач
./gradlew clean build # чистая сборка
./gradlew run         # запуск

About

No description, website, or topics provided.

Resources

Stars

8 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages