feat: редактирование видосов + hero анимация при переходах и зум при кадрировании
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
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)));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user