319 lines
10 KiB
Dart
319 lines
10 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
|
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
|
|
|
import 'package:komet/core/media/video_transcoder.dart';
|
|
import 'package:komet/frontend/widgets/attachment/editor_common.dart';
|
|
import 'package:komet/frontend/widgets/attachment/video_edit.dart';
|
|
|
|
class _FakePathProvider extends PathProviderPlatform
|
|
with MockPlatformInterfaceMixin {
|
|
_FakePathProvider(this.dir);
|
|
final String dir;
|
|
|
|
@override
|
|
Future<String?> getTemporaryPath() async => dir;
|
|
}
|
|
|
|
VideoCropEdit _cropEdit({
|
|
required Size viewport,
|
|
required Rect crop,
|
|
int quarterTurns = 0,
|
|
bool flipH = false,
|
|
double straightenDeg = 0,
|
|
}) => VideoCropEdit(
|
|
viewport: viewport,
|
|
state: CropState(
|
|
quarterTurns: quarterTurns,
|
|
flipH: flipH,
|
|
straightenDeg: straightenDeg,
|
|
cropNorm: Rect.fromLTRB(
|
|
crop.left / viewport.width,
|
|
crop.top / viewport.height,
|
|
crop.right / viewport.width,
|
|
crop.bottom / viewport.height,
|
|
),
|
|
),
|
|
);
|
|
|
|
Future<bool> _hasFfmpeg() async {
|
|
try {
|
|
final probe = await Process.run('ffprobe', const ['-version']);
|
|
return probe.exitCode == 0;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<(int, int, double, bool)> _describe(String path) async {
|
|
final out = await Process.run('ffprobe', [
|
|
'-v',
|
|
'error',
|
|
'-show_entries',
|
|
'stream=codec_type,width,height:format=duration',
|
|
'-of',
|
|
'default=noprint_wrappers=1',
|
|
path,
|
|
]);
|
|
var width = 0;
|
|
var height = 0;
|
|
var duration = 0.0;
|
|
var hasAudio = false;
|
|
for (final line in '${out.stdout}'.split('\n')) {
|
|
final parts = line.trim().split('=');
|
|
if (parts.length != 2) continue;
|
|
switch (parts[0]) {
|
|
case 'width':
|
|
width = int.tryParse(parts[1]) ?? width;
|
|
case 'height':
|
|
height = int.tryParse(parts[1]) ?? height;
|
|
case 'duration':
|
|
duration = double.tryParse(parts[1]) ?? duration;
|
|
case 'codec_type':
|
|
if (parts[1] == 'audio') hasAudio = true;
|
|
}
|
|
}
|
|
return (width, height, duration, hasAudio);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('VideoGeometry', () {
|
|
const source = Size(720, 1280);
|
|
|
|
test('без правок кадр остаётся исходным', () {
|
|
final geometry = VideoGeometry.resolve(null, source);
|
|
expect(geometry.rotationDegrees, 0);
|
|
expect(geometry.cropNorm, const Rect.fromLTRB(0, 0, 1, 1));
|
|
expect(geometry.naturalOutput, source);
|
|
});
|
|
|
|
test('рамка во весь кадр даёт полные доли', () {
|
|
const viewport = Size(400, 800);
|
|
final fitted = const CropGeometry(source: source).fittedRect(viewport);
|
|
final geometry = VideoGeometry.resolve(
|
|
_cropEdit(viewport: viewport, crop: fitted),
|
|
source,
|
|
);
|
|
expect(geometry.cropNorm.left, closeTo(0, 0.001));
|
|
expect(geometry.cropNorm.top, closeTo(0, 0.001));
|
|
expect(geometry.cropNorm.right, closeTo(1, 0.001));
|
|
expect(geometry.cropNorm.bottom, closeTo(1, 0.001));
|
|
expect(geometry.naturalOutput.width, closeTo(720, 0.5));
|
|
expect(geometry.naturalOutput.height, closeTo(1280, 0.5));
|
|
});
|
|
|
|
test('половина рамки кадрирует ровно половину', () {
|
|
const viewport = Size(400, 800);
|
|
final fitted = const CropGeometry(source: source).fittedRect(viewport);
|
|
final half = Rect.fromLTRB(
|
|
fitted.left,
|
|
fitted.top,
|
|
fitted.center.dx,
|
|
fitted.bottom,
|
|
);
|
|
final geometry = VideoGeometry.resolve(
|
|
_cropEdit(viewport: viewport, crop: half),
|
|
source,
|
|
);
|
|
expect(geometry.cropNorm.right, closeTo(0.5, 0.001));
|
|
expect(geometry.naturalOutput.width, closeTo(360, 1));
|
|
expect(geometry.naturalOutput.height, closeTo(1280, 1));
|
|
});
|
|
|
|
test('поворот на четверть меняет стороны местами', () {
|
|
const viewport = Size(400, 800);
|
|
const geometryBase = CropGeometry(source: source, quarterTurns: 1);
|
|
final fitted = geometryBase.fittedRect(viewport);
|
|
final geometry = VideoGeometry.resolve(
|
|
_cropEdit(viewport: viewport, crop: fitted, quarterTurns: 1),
|
|
source,
|
|
);
|
|
expect(geometry.rotationDegrees, closeTo(90, 0.001));
|
|
expect(geometry.rotatedSize.width, closeTo(1280, 0.5));
|
|
expect(geometry.rotatedSize.height, closeTo(720, 0.5));
|
|
});
|
|
|
|
test('отражение переворачивает знак поворота', () {
|
|
const viewport = Size(400, 800);
|
|
const geometryBase = CropGeometry(
|
|
source: source,
|
|
quarterTurns: 1,
|
|
flipH: true,
|
|
);
|
|
final fitted = geometryBase.fittedRect(viewport);
|
|
final geometry = VideoGeometry.resolve(
|
|
_cropEdit(
|
|
viewport: viewport,
|
|
crop: fitted,
|
|
quarterTurns: 1,
|
|
flipH: true,
|
|
),
|
|
source,
|
|
);
|
|
expect(geometry.flipH, isTrue);
|
|
expect(geometry.rotationDegrees, closeTo(-90, 0.001));
|
|
});
|
|
|
|
test('качество ограничивает короткую сторону и держит её чётной', () {
|
|
final geometry = VideoGeometry.resolve(null, source);
|
|
expect(geometry.outputSize(480), const Size(480, 854));
|
|
expect(geometry.outputSize(null), source);
|
|
expect(geometry.outputSize(2000), source);
|
|
});
|
|
|
|
test('матрица цвета переносит сдвиги в четвёртый столбец', () {
|
|
final adjust = ColorAdjust(warmth: 0.5);
|
|
final gl = glColorMatrix(adjust)!;
|
|
final base = adjust.matrix();
|
|
expect(gl.length, 16);
|
|
expect(gl[0], closeTo(base[0], 1e-9));
|
|
expect(gl[12], closeTo(base[4] / 255, 1e-9));
|
|
expect(gl[14], closeTo(base[14] / 255, 1e-9));
|
|
expect(gl[15], 1);
|
|
expect(glColorMatrix(ColorAdjust()), isNull);
|
|
});
|
|
});
|
|
|
|
group('экспорт видео', () {
|
|
late Directory tmp;
|
|
|
|
setUp(() {
|
|
tmp = Directory.systemTemp.createTempSync('komet_video_test');
|
|
PathProviderPlatform.instance = _FakePathProvider(tmp.path);
|
|
});
|
|
|
|
tearDown(() => tmp.deleteSync(recursive: true));
|
|
|
|
test('обрезает, кадрирует, масштабирует и убирает звук', () async {
|
|
if (!await _hasFfmpeg()) {
|
|
markTestSkipped('ffmpeg недоступен');
|
|
return;
|
|
}
|
|
final input = File('${tmp.path}/source.mp4');
|
|
final make = await Process.run('ffmpeg', [
|
|
'-y',
|
|
'-v',
|
|
'error',
|
|
'-f',
|
|
'lavfi',
|
|
'-i',
|
|
'testsrc2=size=640x480:rate=30:duration=4',
|
|
'-f',
|
|
'lavfi',
|
|
'-i',
|
|
'sine=frequency=440:duration=4',
|
|
'-c:v',
|
|
'libx264',
|
|
'-pix_fmt',
|
|
'yuv420p',
|
|
'-c:a',
|
|
'aac',
|
|
input.path,
|
|
]);
|
|
expect(make.exitCode, 0, reason: '${make.stderr}');
|
|
|
|
final info = await VideoTranscoder.probe(input.path);
|
|
expect(info, isNotNull);
|
|
expect(info!.width, 640);
|
|
expect(info.height, 480);
|
|
expect(info.hasAudio, isTrue);
|
|
expect(info.durationMs, greaterThan(3500));
|
|
|
|
final source = Size(info.width.toDouble(), info.height.toDouble());
|
|
final edit = VideoEditState()
|
|
..sourceDuration = Duration(milliseconds: info.durationMs)
|
|
..start = const Duration(milliseconds: 500)
|
|
..end = const Duration(milliseconds: 2500)
|
|
..muted = true
|
|
..maxShortSide = 240
|
|
..adjust = ColorAdjust(contrast: 0.3, vignette: 0.4);
|
|
final viewport = const Size(400, 300);
|
|
final fitted = CropGeometry(source: source).fittedRect(viewport);
|
|
edit.crop = _cropEdit(
|
|
viewport: viewport,
|
|
crop: Rect.fromLTRB(
|
|
fitted.left,
|
|
fitted.top,
|
|
fitted.center.dx,
|
|
fitted.bottom,
|
|
),
|
|
);
|
|
|
|
final spec = await buildVideoExportSpec(
|
|
edit,
|
|
input.path,
|
|
source,
|
|
info.fps,
|
|
);
|
|
expect(spec, isNotNull);
|
|
expect(spec!.overlayPath, isNotNull);
|
|
expect(File(spec.overlayPath!).existsSync(), isTrue);
|
|
|
|
final ok = await VideoTranscoder.export(spec);
|
|
expect(ok, isTrue);
|
|
|
|
final (width, height, duration, hasAudio) = await _describe(spec.output);
|
|
expect(width, spec.outWidth);
|
|
expect(height, spec.outHeight);
|
|
expect(width, 240);
|
|
expect(hasAudio, isFalse);
|
|
expect(duration, closeTo(2.0, 0.35));
|
|
}, timeout: const Timeout(Duration(minutes: 3)));
|
|
|
|
test('поворачивает и отражает кадр', () async {
|
|
if (!await _hasFfmpeg()) {
|
|
markTestSkipped('ffmpeg недоступен');
|
|
return;
|
|
}
|
|
final input = File('${tmp.path}/rotate.mp4');
|
|
final make = await Process.run('ffmpeg', [
|
|
'-y',
|
|
'-v',
|
|
'error',
|
|
'-f',
|
|
'lavfi',
|
|
'-i',
|
|
'testsrc2=size=640x480:rate=30:duration=2',
|
|
'-c:v',
|
|
'libx264',
|
|
'-pix_fmt',
|
|
'yuv420p',
|
|
input.path,
|
|
]);
|
|
expect(make.exitCode, 0, reason: '${make.stderr}');
|
|
|
|
const source = Size(640, 480);
|
|
const viewport = Size(400, 800);
|
|
const base = CropGeometry(source: source, quarterTurns: 1, flipH: true);
|
|
final edit = VideoEditState()
|
|
..sourceDuration = const Duration(seconds: 2)
|
|
..end = const Duration(seconds: 2)
|
|
..crop = _cropEdit(
|
|
viewport: viewport,
|
|
crop: base.fittedRect(viewport),
|
|
quarterTurns: 1,
|
|
flipH: true,
|
|
);
|
|
|
|
final spec = await buildVideoExportSpec(edit, input.path, source, 30);
|
|
expect(spec, isNotNull);
|
|
expect(spec!.rotationDegrees, closeTo(-90, 0.001));
|
|
expect(spec.flipH, isTrue);
|
|
expect(spec.outWidth, 480);
|
|
expect(spec.outHeight, 640);
|
|
|
|
final ok = await VideoTranscoder.export(spec);
|
|
expect(ok, isTrue);
|
|
|
|
final (width, height, _, _) = await _describe(spec.output);
|
|
expect(width, 480);
|
|
expect(height, 640);
|
|
}, timeout: const Timeout(Duration(minutes: 3)));
|
|
});
|
|
}
|