Files
Qlyra/test/video_editor_test.dart
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

325 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:qlyra/core/media/video_transcoder.dart';
import 'package:qlyra/frontend/widgets/attachment/editor_common.dart';
import 'package:qlyra/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('qlyra_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)));
});
}