Merge branch 'feature/voice-video-notes' into feature/FullStack

This commit is contained in:
klockky
2026-06-25 17:36:01 +03:00
20 changed files with 3056 additions and 87 deletions
@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет
/// квадрат 480×480 сразу при съёмке — как официальный клиент. Превью отдаётся
/// через Flutter [Texture] по [textureId]. media3-перекод не используется
/// (серверный валидатор принимает только нативно записанный MP4).
class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId;
bool get isAvailable => Platform.isAndroid;
Future<bool> init({bool front = true}) async {
if (!isAvailable) return false;
try {
final res = await _channel.invokeMapMethod<String, dynamic>('init', {
'front': front,
});
textureId = res?['textureId'] as int?;
return textureId != null;
} catch (e) {
logger.w('NativeVideoNoteRecorder.init: $e');
return false;
}
}
Future<bool> start() async {
if (!isAvailable) return false;
try {
await _channel.invokeMethod('start');
return true;
} catch (e) {
logger.w('NativeVideoNoteRecorder.start: $e');
return false;
}
}
Future<String?> stop() async {
if (!isAvailable) return null;
try {
return await _channel.invokeMethod<String>('stop');
} catch (e) {
logger.w('NativeVideoNoteRecorder.stop: $e');
return null;
}
}
Future<void> dispose() async {
if (!isAvailable) return;
try {
await _channel.invokeMethod('dispose');
} catch (_) {}
textureId = null;
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:opus_dart/opus_dart.dart';
import '../utils/logger.dart';
/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет
/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а
/// Ogg-контейнер (страницы, лейсинг, CRC32, OpusHead/OpusTags) собирается здесь.
///
/// Формат совпадает с тем, что шлёт оригинальный клиент: моно, 48000 Hz,
/// pre-skip 312, vendor «libopus unknown».
class OpusOggEncoder {
static const int _sampleRate = 48000;
static const int _channels = 1;
static const int _preSkip = 312;
static const int _frameSamples = 960; // 20 мс @ 48 кГц
static const int _serial = 0x4b6f6d74; // 'Komt'
static const String _vendor = 'libopus unknown';
static bool _initialized = false;
static bool _available = false;
/// Лениво загружает libopus и инициализирует opus_dart: на Windows —
/// вендоренную `opus.dll` рядом с exe, на Android — через
/// `opus_flutter_android`. Возвращает `false`, если кодек недоступен.
static Future<bool> ensureAvailable() async {
if (_initialized) return _available;
_initialized = true;
try {
// libopus.so на Android бандлится плагином opus_flutter_android,
// opus.dll — вендоренная рядом с exe на Windows.
final String libName;
if (Platform.isWindows) {
libName = 'opus.dll';
} else if (Platform.isAndroid) {
libName = 'libopus.so';
} else {
return false;
}
initOpus(DynamicLibrary.open(libName) as dynamic);
_available = true;
} catch (e) {
logger.w('OpusOggEncoder: libopus недоступна: $e');
_available = false;
}
return _available;
}
/// Парсит WAV (16-bit PCM моно 48 кГц) и кодирует его в Ogg/Opus.
/// Возвращает `null`, если кодек недоступен или WAV не распознан.
static Future<Uint8List?> wavToOggOpus(Uint8List wav) async {
if (!await ensureAvailable()) return null;
final pcm = _pcmFromWav(wav);
if (pcm == null || pcm.isEmpty) return null;
try {
return _encodePcm(pcm);
} catch (e) {
logger.w('OpusOggEncoder: ошибка кодирования: $e');
return null;
}
}
static Uint8List _encodePcm(Int16List pcm) {
final encoder = SimpleOpusEncoder(
sampleRate: _sampleRate,
channels: _channels,
application: Application.audio,
);
final packets = <Uint8List>[];
try {
for (var off = 0; off < pcm.length; off += _frameSamples) {
final end = off + _frameSamples;
final Int16List frame;
if (end <= pcm.length) {
frame = Int16List.sublistView(pcm, off, end);
} else {
frame = Int16List(_frameSamples)..setRange(0, pcm.length - off, pcm, off);
}
packets.add(encoder.encode(input: frame));
}
} finally {
encoder.destroy();
}
return _buildOgg(packets, totalSamples: pcm.length);
}
static Uint8List _buildOgg(List<Uint8List> packets, {required int totalSamples}) {
final out = BytesBuilder();
var seq = 0;
out.add(_page(headerType: 0x02, granulePos: 0, seq: seq++, packets: [_opusHead()]));
out.add(_page(headerType: 0x00, granulePos: 0, seq: seq++, packets: [_opusTags()]));
var pagePackets = <Uint8List>[];
var pageSegments = 0;
var samples = 0;
void flush({required bool last}) {
final granule = last ? totalSamples + _preSkip : samples + _preSkip;
out.add(_page(
headerType: last ? 0x04 : 0x00,
granulePos: granule,
seq: seq++,
packets: pagePackets,
));
pagePackets = <Uint8List>[];
pageSegments = 0;
}
for (var i = 0; i < packets.length; i++) {
final p = packets[i];
final segs = (p.length ~/ 255) + 1;
if (pagePackets.isNotEmpty && pageSegments + segs > 255) {
flush(last: false);
}
pagePackets.add(p);
pageSegments += segs;
samples += _frameSamples;
}
flush(last: true);
return out.toBytes();
}
static Uint8List _opusHead() {
final b = BytesBuilder();
b.add(_ascii('OpusHead'));
final d = ByteData(11);
d.setUint8(0, 1); // version
d.setUint8(1, _channels);
d.setUint16(2, _preSkip, Endian.little);
d.setUint32(4, _sampleRate, Endian.little);
d.setUint16(8, 0, Endian.little); // output gain
d.setUint8(10, 0); // channel mapping family
b.add(d.buffer.asUint8List());
return b.toBytes();
}
static Uint8List _opusTags() {
final vendor = _ascii(_vendor);
final b = BytesBuilder();
b.add(_ascii('OpusTags'));
final len = ByteData(4)..setUint32(0, vendor.length, Endian.little);
b.add(len.buffer.asUint8List());
b.add(vendor);
final count = ByteData(4)..setUint32(0, 0, Endian.little);
b.add(count.buffer.asUint8List());
return b.toBytes();
}
static Uint8List _page({
required int headerType,
required int granulePos,
required int seq,
required List<Uint8List> packets,
}) {
final segs = <int>[];
for (final p in packets) {
var len = p.length;
while (len >= 255) {
segs.add(255);
len -= 255;
}
segs.add(len);
}
final header = Uint8List(27 + segs.length);
final hd = ByteData.sublistView(header);
header.setRange(0, 4, _ascii('OggS'));
hd.setUint8(4, 0); // stream structure version
hd.setUint8(5, headerType);
hd.setUint64(6, granulePos, Endian.little);
hd.setUint32(14, _serial, Endian.little);
hd.setUint32(18, seq, Endian.little);
hd.setUint32(22, 0, Endian.little); // CRC placeholder
hd.setUint8(26, segs.length);
header.setRange(27, 27 + segs.length, segs);
final body = BytesBuilder();
body.add(header);
for (final p in packets) {
body.add(p);
}
final page = body.toBytes();
final crc = _crc32(page);
ByteData.sublistView(page).setUint32(22, crc, Endian.little);
return page;
}
static Uint8List _ascii(String s) => Uint8List.fromList(s.codeUnits);
static final Uint32List _crcTable = _buildCrcTable();
static Uint32List _buildCrcTable() {
final t = Uint32List(256);
for (var i = 0; i < 256; i++) {
var r = (i << 24) & 0xffffffff;
for (var j = 0; j < 8; j++) {
if ((r & 0x80000000) != 0) {
r = ((r << 1) ^ 0x04c11db7) & 0xffffffff;
} else {
r = (r << 1) & 0xffffffff;
}
}
t[i] = r;
}
return t;
}
static int _crc32(Uint8List data) {
var crc = 0;
for (final b in data) {
crc = (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) &
0xffffffff;
}
return crc & 0xffffffff;
}
static Int16List? _pcmFromWav(Uint8List bytes) {
if (bytes.length < 12) return null;
if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' ||
String.fromCharCodes(bytes, 8, 12) != 'WAVE') {
return null;
}
final bd = ByteData.sublistView(bytes);
var off = 12;
while (off + 8 <= bytes.length) {
final id = String.fromCharCodes(bytes, off, off + 4);
final size = bd.getUint32(off + 4, Endian.little);
final body = off + 8;
if (id == 'data') {
final end = (body + size) <= bytes.length ? body + size : bytes.length;
final n = (end - body) ~/ 2;
final pcm = Int16List(n);
for (var i = 0; i < n; i++) {
pcm[i] = bd.getInt16(body + i * 2, Endian.little);
}
return pcm;
}
off = body + size + (size & 1);
}
return null;
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Центр-кроп записанного видео в квадрат для видеосообщений-кружков.
/// На Android выполняется нативно (media3 Transformer, без искажений —
/// заполняет квадрат и обрезает лишнее по бокам). На других платформах
/// возвращает `null` (кружки там не записываются).
class VideoNoteCropper {
static const _channel = MethodChannel('ru.komet.app/video');
static Future<String?> cropSquare(String input, {int size = 480}) async {
if (!Platform.isAndroid) return null;
try {
final dot = input.lastIndexOf('.');
final base = dot > 0 ? input.substring(0, dot) : input;
final output = '${base}_sq.mp4';
final res = await _channel.invokeMethod<String>('cropSquare', {
'input': input,
'output': output,
'size': size,
});
return res;
} catch (e) {
logger.w('VideoNoteCropper: $e');
return null;
}
}
}