feat: редактирование видосов + hero анимация при переходах и зум при кадрировании

This commit is contained in:
Jganenokk
2026-08-16 21:23:56 +07:00
parent 53f0587c31
commit 3e6254bc5d
25 changed files with 6372 additions and 2261 deletions
+59
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'video_transcoder.dart' show VideoInfo;
class DesktopVideoProbe {
static const Duration _timeout = Duration(seconds: 6);
static const int _maxCache = 60;
@@ -13,6 +16,8 @@ class DesktopVideoProbe {
static final Map<String, Duration?> _durations = {};
static final Map<String, Uint8List?> _thumbs = {};
static Future<bool> toolsAvailable() => _toolsAvailable();
static Future<bool> _toolsAvailable() async {
if (_hasTools != null) return _hasTools!;
if (!supported) return _hasTools = false;
@@ -79,6 +84,60 @@ class DesktopVideoProbe {
return result;
}
static Future<VideoInfo?> info(String path) async {
if (!await _toolsAvailable()) return null;
try {
final out = await Process.run('ffprobe', [
'-v',
'error',
'-show_entries',
'stream=codec_type,width,height,r_frame_rate:format=duration',
'-of',
'json',
path,
]).timeout(_timeout);
final root = jsonDecode('${out.stdout}') as Map<String, dynamic>;
final streams = (root['streams'] as List?) ?? const [];
Map<String, dynamic>? video;
var hasAudio = false;
for (final raw in streams) {
final s = raw as Map<String, dynamic>;
if (s['codec_type'] == 'video') {
video ??= s;
} else if (s['codec_type'] == 'audio') {
hasAudio = true;
}
}
if (video == null) return null;
final seconds =
double.tryParse('${(root['format'] as Map?)?['duration']}') ?? 0;
return VideoInfo(
width: (video['width'] as num?)?.toInt() ?? 0,
height: (video['height'] as num?)?.toInt() ?? 0,
durationMs: (seconds * 1000).round(),
fps: _parseRate('${video['r_frame_rate']}'),
hasAudio: hasAudio,
);
} catch (_) {
return null;
}
}
static double _parseRate(String value) {
final parts = value.split('/');
if (parts.length == 2) {
final num = double.tryParse(parts[0]);
final den = double.tryParse(parts[1]);
if (num != null && den != null && den > 0) return num / den;
}
return double.tryParse(value) ?? 30;
}
static Future<Uint8List?> frameAt(String path, int timeMs, int size) async {
if (!await _toolsAvailable()) return null;
return _grabFrame(path, size, (timeMs / 1000).toStringAsFixed(3));
}
static Future<Uint8List?> thumbnail(String path, int size) async {
final key = '$path@$size';
if (_thumbs.containsKey(key)) return _thumbs[key];
+363
View File
@@ -0,0 +1,363 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../utils/logger.dart';
import 'desktop_video_probe.dart';
class VideoInfo {
final int width;
final int height;
final int durationMs;
final double fps;
final bool hasAudio;
const VideoInfo({
required this.width,
required this.height,
required this.durationMs,
required this.fps,
required this.hasAudio,
});
}
class VideoExportSpec {
final String input;
final String output;
final int? startMs;
final int? endMs;
final bool removeAudio;
final double rotationDegrees;
final bool flipH;
final Rect? crop;
final int outWidth;
final int outHeight;
final List<double>? rgbMatrix;
final String? overlayPath;
final int? bitrate;
const VideoExportSpec({
required this.input,
required this.output,
required this.outWidth,
required this.outHeight,
this.startMs,
this.endMs,
this.removeAudio = false,
this.rotationDegrees = 0,
this.flipH = false,
this.crop,
this.rgbMatrix,
this.overlayPath,
this.bitrate,
});
bool get hasGeometry =>
rotationDegrees.abs() > 0.01 ||
flipH ||
(crop != null && crop != const Rect.fromLTRB(0, 0, 1, 1));
}
class VideoTranscoder {
static const _channel = MethodChannel('ru.komet.app/video');
static Process? _desktopProcess;
static bool _desktopCancelled = false;
static bool get supported =>
Platform.isAndroid || (DesktopVideoProbe.supported && _ffmpegReady);
static bool _ffmpegReady = false;
static Future<bool> ensureAvailable() async {
if (Platform.isAndroid) return true;
if (!DesktopVideoProbe.supported) return false;
_ffmpegReady = await DesktopVideoProbe.toolsAvailable();
return _ffmpegReady;
}
static Future<VideoInfo?> probe(String path) async {
if (Platform.isAndroid) {
try {
final res = await _channel.invokeMapMethod<String, dynamic>('probe', {
'input': path,
});
if (res == null) return null;
return VideoInfo(
width: (res['width'] as num?)?.toInt() ?? 0,
height: (res['height'] as num?)?.toInt() ?? 0,
durationMs: (res['durationMs'] as num?)?.toInt() ?? 0,
fps: (res['fps'] as num?)?.toDouble() ?? 30,
hasAudio: res['hasAudio'] == true,
);
} catch (e) {
logger.w('VideoTranscoder.probe: $e');
return null;
}
}
if (!await ensureAvailable()) return null;
return DesktopVideoProbe.info(path);
}
static Future<List<Uint8List?>> frames(
String path,
List<int> timesMs, {
int size = 256,
bool precise = false,
}) async {
if (timesMs.isEmpty) return const [];
if (Platform.isAndroid) {
try {
final res = await _channel.invokeListMethod<Object?>('frames', {
'input': path,
'times': timesMs,
'size': size,
'precise': precise,
});
if (res == null) return List.filled(timesMs.length, null);
return res.map((e) => e as Uint8List?).toList();
} catch (e) {
logger.w('VideoTranscoder.frames: $e');
return List.filled(timesMs.length, null);
}
}
if (!await ensureAvailable()) return List.filled(timesMs.length, null);
final out = <Uint8List?>[];
for (final t in timesMs) {
out.add(await DesktopVideoProbe.frameAt(path, t, size));
}
return out;
}
static Future<File?> outputFile(String prefix) async {
final dir = await getTemporaryDirectory();
return File(
p.join(
dir.path,
'komet_${prefix}_${DateTime.now().microsecondsSinceEpoch}.mp4',
),
);
}
static Future<bool> export(
VideoExportSpec spec, {
void Function(double progress)? onProgress,
}) async {
if (Platform.isAndroid) return _exportAndroid(spec, onProgress);
if (!await ensureAvailable()) return false;
return _exportFfmpeg(spec, onProgress);
}
static Future<void> cancel() async {
if (Platform.isAndroid) {
try {
await _channel.invokeMethod<void>('editCancel');
} catch (_) {}
return;
}
_desktopCancelled = true;
_desktopProcess?.kill();
}
static Future<bool> _exportAndroid(
VideoExportSpec spec,
void Function(double)? onProgress,
) async {
final poll = onProgress == null
? null
: Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final value = await _channel.invokeMethod<int>('editProgress');
if (value != null && value >= 0) onProgress(value / 100);
} catch (_) {}
});
try {
final ok = await _channel.invokeMethod<bool>('edit', _androidArgs(spec));
return ok == true;
} catch (e) {
logger.w('VideoTranscoder.export: $e');
return false;
} finally {
poll?.cancel();
}
}
static Map<String, dynamic> _androidArgs(VideoExportSpec spec) {
final crop = spec.crop;
return {
'input': spec.input,
'output': spec.output,
'startMs': spec.startMs,
'endMs': spec.endMs,
'removeAudio': spec.removeAudio,
'rotationDegrees': spec.rotationDegrees,
'flipH': spec.flipH,
'crop': crop == null
? null
: <double>[
crop.left * 2 - 1,
crop.right * 2 - 1,
1 - crop.bottom * 2,
1 - crop.top * 2,
],
'outWidth': spec.outWidth,
'outHeight': spec.outHeight,
'rgbMatrix': spec.rgbMatrix,
'overlay': spec.overlayPath,
'bitrate': spec.bitrate,
};
}
static Future<bool> _exportFfmpeg(
VideoExportSpec spec,
void Function(double)? onProgress,
) async {
_desktopCancelled = false;
File? lut;
try {
final args = <String>[
'-y',
'-v',
'error',
'-progress',
'pipe:1',
'-nostats',
];
final startMs = spec.startMs ?? 0;
if (startMs > 0) {
args.addAll(['-ss', (startMs / 1000).toStringAsFixed(3)]);
}
args.addAll(['-i', spec.input]);
final overlay = spec.overlayPath;
if (overlay != null) args.addAll(['-i', overlay]);
final endMs = spec.endMs;
if (endMs != null && endMs > startMs) {
args.addAll(['-t', ((endMs - startMs) / 1000).toStringAsFixed(3)]);
}
final matrix = spec.rgbMatrix;
if (matrix != null) lut = await _writeCubeLut(matrix);
final chain = <String>[];
if (spec.flipH) chain.add('hflip');
final rotation = spec.rotationDegrees;
if (rotation.abs() > 0.01) {
final radians = -rotation * math.pi / 180;
chain.add(
'rotate=${radians.toStringAsFixed(6)}:'
"ow='rotw(${radians.toStringAsFixed(6)})':"
"oh='roth(${radians.toStringAsFixed(6)})':c=black",
);
}
final crop = spec.crop;
if (crop != null && crop != const Rect.fromLTRB(0, 0, 1, 1)) {
chain.add(
'crop=iw*${crop.width.toStringAsFixed(6)}:'
'ih*${crop.height.toStringAsFixed(6)}:'
'iw*${crop.left.toStringAsFixed(6)}:'
'ih*${crop.top.toStringAsFixed(6)}',
);
}
chain.add('scale=${spec.outWidth}:${spec.outHeight}');
if (lut != null) {
chain.add("lut3d=file='${lut.path.replaceAll("'", r"\'")}'");
}
chain.add('format=yuv420p');
if (overlay != null) {
args.addAll([
'-filter_complex',
'[0:v]${chain.join(',')}[base];[base][1:v]overlay=0:0',
]);
} else {
args.addAll(['-vf', chain.join(',')]);
}
args.addAll([
'-c:v',
'libx264',
'-preset',
'veryfast',
'-pix_fmt',
'yuv420p',
]);
final bitrate = spec.bitrate;
if (bitrate != null) {
args.addAll(['-b:v', '$bitrate', '-maxrate', '$bitrate']);
}
if (spec.removeAudio) {
args.add('-an');
} else {
args.addAll(['-c:a', 'aac', '-b:a', '128k']);
}
args.addAll(['-movflags', '+faststart', spec.output]);
final process = await Process.start('ffmpeg', args);
_desktopProcess = process;
final totalMs = (endMs ?? 0) - startMs;
final progress = process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
if (onProgress == null || totalMs <= 0) return;
if (!line.startsWith('out_time_ms=')) return;
final us = int.tryParse(line.substring(12).trim());
if (us == null) return;
onProgress((us / 1000 / totalMs).clamp(0.0, 1.0));
});
final stderr = process.stderr.transform(utf8.decoder).join();
final code = await process.exitCode;
await progress.cancel();
if (code != 0 && !_desktopCancelled) {
logger.w('ffmpeg exited $code: ${await stderr}');
}
return code == 0;
} catch (e) {
logger.w('VideoTranscoder ffmpeg: $e');
return false;
} finally {
_desktopProcess = null;
lut?.delete().then((_) {}, onError: (_) {});
}
}
static Future<File> _writeCubeLut(List<double> m) async {
const n = 17;
final buffer = StringBuffer('LUT_3D_SIZE $n\n');
double apply(int row, double r, double g, double b) =>
(m[row] * r + m[4 + row] * g + m[8 + row] * b + m[12 + row]).clamp(
0.0,
1.0,
);
for (var bi = 0; bi < n; bi++) {
for (var gi = 0; gi < n; gi++) {
for (var ri = 0; ri < n; ri++) {
final r = ri / (n - 1);
final g = gi / (n - 1);
final b = bi / (n - 1);
buffer.writeln(
'${apply(0, r, g, b).toStringAsFixed(6)} '
'${apply(1, r, g, b).toStringAsFixed(6)} '
'${apply(2, r, g, b).toStringAsFixed(6)}',
);
}
}
}
final dir = await getTemporaryDirectory();
final file = File(
p.join(
dir.path,
'komet_lut_${DateTime.now().microsecondsSinceEpoch}.cube',
),
);
await file.writeAsString(buffer.toString());
return file;
}
}
+22 -5
View File
@@ -17,6 +17,7 @@ import 'package:komet/backend/modules/webapp.dart';
import 'package:komet/frontend/screens/webapp/open_mini_app.dart';
import 'package:komet/frontend/widgets/sending_clock_icon.dart';
import 'package:komet/core/media/desktop_video_probe.dart';
import 'package:komet/core/media/video_transcoder.dart';
import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
@@ -6312,17 +6313,33 @@ class _ChatScreenState extends State<ChatScreen>
await video.item.originFile();
if (file == null || !mounted) return;
final edited = video.editedFile;
var durationMs = video.item.duration?.inMilliseconds;
var dims = await video.item.dimensions();
Uint8List? thumbBytes;
if (edited != null) {
final info = await VideoTranscoder.probe(edited.path);
if (info != null) {
if (info.durationMs > 0) durationMs = info.durationMs;
if (info.width > 0 && info.height > 0) dims = (info.width, info.height);
}
final frames = await VideoTranscoder.frames(
edited.path,
const [0],
size: 512,
);
if (frames.isNotEmpty) thumbBytes = frames.first;
}
if (durationMs == null && DesktopVideoProbe.supported) {
durationMs = (await DesktopVideoProbe.duration(
file.path,
))?.inMilliseconds;
}
final dims = await video.item.dimensions();
Uint8List? thumbBytes;
try {
thumbBytes = await video.item.thumbnail(512);
} catch (_) {}
if (thumbBytes == null) {
try {
thumbBytes = await video.item.thumbnail(512);
} catch (_) {}
}
if (!mounted) return;
final thumbData = thumbBytes == null || thumbBytes.isEmpty
? null
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
@@ -13,11 +14,14 @@ import 'package:komet/core/config/app_frost.dart';
import 'package:komet/core/config/app_nav_pill_style.dart';
import 'package:komet/core/config/app_visual_style.dart';
import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/media/video_transcoder.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/widgets/attachment/contact_picker_page.dart';
import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart';
import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
import 'package:komet/frontend/widgets/attachment/video_edit.dart';
import 'package:komet/frontend/widgets/attachment/video_preview_screen.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/sheet_helpers.dart';
import 'package:komet/frontend/widgets/sliding_pill_nav.dart';
@@ -91,6 +95,9 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
final Map<String, GlobalKey<_ThumbnailState>> _thumbKeys = {};
final Map<String, PhotoEditState> _edits = {};
final Map<String, VideoEditState> _videoEdits = {};
bool _videoEditorReady = false;
bool _exporting = false;
final Set<String> _tempFiles = {};
final Set<String> _sentFiles = {};
final TextEditingController _captionCtrl = TextEditingController();
@@ -116,6 +123,9 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
} else {
_loadGallery();
}
VideoTranscoder.ensureAvailable().then((ready) {
if (mounted && ready) setState(() => _videoEditorReady = true);
});
}
@override
@@ -164,15 +174,35 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
_thumbKeys.putIfAbsent(id, () => GlobalKey<_ThumbnailState>());
void _openPreview(GalleryItem item) {
if (item.isVideo) {
_toggleSelection(item);
return;
}
final thumbKey = _thumbKey(item.id);
final hero = PhotoHeroController(
origin: () => photoHeroRect(thumbKey),
image: thumbKey.currentState?.provider,
);
if (item.isVideo) {
final edit = _videoEdits.putIfAbsent(item.id, VideoEditState.new);
Navigator.of(context).push(
PhotoHeroRoute<void>(
hero: hero,
builder: (_) => VideoPreviewScreen(
item: item,
hero: hero,
title: widget.title,
selectedIds: _selected,
editable: _videoEditorReady,
edit: edit,
onToggleSelection: () => _toggleSelection(item),
onSend: () => _sendSelection(fallback: item),
onEditChanged: () {
if (mounted) setState(() {});
},
initialCaption: _captionCtrl.text,
onCaptionChanged: (text) => _captionCtrl.text = text,
),
),
);
return;
}
Navigator.of(context).push(
PhotoHeroRoute<void>(
hero: hero,
@@ -216,13 +246,114 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
_openPreview(GalleryItem.fromFile(File(shot.path)));
}
void _sendSelection({GalleryItem? fallback}) {
Future<bool> _exportVideos(List<GalleryItem> chosen) async {
final jobs = <(GalleryItem, VideoEditState)>[];
for (final item in chosen) {
if (!item.isVideo) continue;
final edit = _videoEdits[item.id];
if (edit != null && edit.hasEdits) jobs.add((item, edit));
}
if (jobs.isEmpty) return true;
final progress = ValueNotifier<double>(0);
final navigator = Navigator.of(context, rootNavigator: true);
var cancelled = false;
setState(() => _exporting = true);
unawaited(
showGeneralDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black54,
pageBuilder: (_, _, _) => _ExportProgress(
progress: progress,
onCancel: () {
cancelled = true;
VideoTranscoder.cancel();
},
),
),
);
var ok = true;
for (final (item, edit) in jobs) {
final file = item.localFile ?? await item.originFile();
if (file == null) {
ok = false;
break;
}
final info = await VideoTranscoder.probe(file.path);
var source = info != null && info.width > 0 && info.height > 0
? Size(info.width.toDouble(), info.height.toDouble())
: Size.zero;
if (source.isEmpty) {
final dims = await item.dimensions();
if (dims == null) {
ok = false;
break;
}
source = Size(dims.$1.toDouble(), dims.$2.toDouble());
}
final signature = edit.signature(source);
if (edit.exported != null && edit.exportedSignature == signature) {
continue;
}
progress.value = 0;
final spec = await buildVideoExportSpec(
edit,
file.path,
source,
info?.fps ?? 30,
);
if (spec == null) {
ok = false;
break;
}
final done = await VideoTranscoder.export(
spec,
onProgress: (value) => progress.value = value,
);
final overlay = spec.overlayPath;
if (overlay != null) {
File(overlay).delete().then((_) {}, onError: (_) {});
}
if (!done) {
ok = false;
break;
}
edit.exported = File(spec.output);
edit.exportedSignature = signature;
_tempFiles.add(spec.output);
}
progress.dispose();
navigator.pop();
if (!mounted) return false;
setState(() => _exporting = false);
if (!ok && !cancelled) {
showCustomNotification(
context,
AppLocalizations.of(context)!.videoEditorExportFailed,
);
}
return ok;
}
Future<void> _sendSelection({GalleryItem? fallback}) async {
if (_exporting) return;
final ids = _selected.value;
var chosen = _items.where((it) => ids.contains(it.id)).toList();
if (chosen.isEmpty && fallback != null) chosen = [fallback];
if (chosen.isEmpty) return;
if (!await _exportVideos(chosen) || !mounted) return;
final picked = chosen
.map((it) => PickedPhoto(item: it, editedFile: _edits[it.id]?.working))
.map(
(it) => PickedPhoto(
item: it,
editedFile: it.isVideo
? _videoEdits[it.id]?.exported
: _edits[it.id]?.working,
),
)
.toList();
final callback = widget.onSend;
if (callback != null) {
@@ -1308,3 +1439,51 @@ class _ThumbnailState extends State<_Thumbnail> {
: null,
);
}
class _ExportProgress extends StatelessWidget {
final ValueListenable<double> progress;
final VoidCallback onCancel;
const _ExportProgress({required this.progress, required this.onCancel});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Center(
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 22, 24, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ValueListenableBuilder<double>(
valueListenable: progress,
builder: (context, value, _) => SizedBox(
width: 46,
height: 46,
child: CircularProgressIndicator(
value: value <= 0 ? null : value,
strokeWidth: 3,
),
),
),
const SizedBox(height: 16),
Text(
l10n.videoEditorProcessing,
style: TextStyle(color: cs.onSurface, fontSize: 15),
),
const SizedBox(height: 6),
TextButton(
onPressed: onCancel,
child: Text(l10n.photoEditorCancel),
),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -10,11 +9,10 @@ import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import '../../../core/config/app_colors.dart';
import 'editor_common.dart';
import 'preview_chrome.dart';
import '../small_spinner.dart';
const Color _kBar = Color(0xFF1E1E1E);
class MediaPreviewScreen extends StatefulWidget {
final GalleryItem item;
final PhotoHeroController hero;
@@ -52,9 +50,12 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
text: widget.initialCaption,
);
final TransformationController _zoom = TransformationController();
final GlobalKey _stageKey = GlobalKey();
File? _workingFile;
File? _cropSource;
CropState? _cropState;
Size? _workingSize;
PhotoHeroController? _activeHero;
@override
void initState() {
@@ -80,6 +81,30 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
void _setWorkingFile(File file) {
_workingFile = file;
widget.hero.image.value = FileImage(file);
_resolveWorkingSize(file);
}
Future<void> _resolveWorkingSize(File file) async {
final dims = await imageFileDimensions(file);
if (!mounted || dims == null || _workingFile?.path != file.path) return;
_workingSize = Size(dims.$1.toDouble(), dims.$2.toDouble());
}
Rect? _stageOrigin() {
if (_zoom.value.getMaxScaleOnAxis() > 1.01) return null;
final box = photoHeroRect(_stageKey);
final size = _workingSize;
if (box == null || size == null) return null;
return inscribeRect(size, box);
}
Future<void> _flight(File file) async {
await _resolveWorkingSize(file);
if (!mounted) return;
final provider = FileImage(file);
await precacheImage(provider, context);
if (!mounted) return;
_activeHero?.image.value = provider;
}
@override
@@ -97,15 +122,21 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
widget.onSend();
}
Future<T?> _pushEditor<T>(Widget editor) {
return Navigator.of(context).push<T>(
PageRouteBuilder<T>(
opaque: true,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => editor,
),
Future<T?> _pushEditor<T>(Widget Function() builder) async {
final file = _workingFile;
if (file == null) return null;
final hero = PhotoHeroController(
origin: _stageOrigin,
image: FileImage(file),
);
_activeHero = hero;
try {
return await Navigator.of(
context,
).push<T>(PhotoHeroRoute<T>(hero: hero, builder: (_) => builder()));
} finally {
_activeHero = null;
}
}
void _reportEdit() {
@@ -129,17 +160,24 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
final source = _cropSource ??=
widget.item.localFile ?? await widget.item.originFile();
if (source == null || !mounted) return;
final result = await _pushEditor<CropResult>(
PhotoCropEditor(source: source, initialState: _cropState),
await _pushEditor<CropResult>(
() => PhotoCropEditor(
source: source,
initialState: _cropState,
onPreview: _applyCrop,
),
);
if (result != null && mounted) {
final old = _workingFile;
_cropState = result.state;
widget.tempFiles.add(result.file.path);
setState(() => _setWorkingFile(result.file));
_reportEdit();
_disposeTemp(old, {result.file.path, _cropSource?.path ?? ''});
}
}
Future<void> _applyCrop(CropResult result) async {
if (!mounted) return;
final old = _workingFile;
_cropState = result.state;
widget.tempFiles.add(result.file.path);
setState(() => _setWorkingFile(result.file));
_reportEdit();
_disposeTemp(old, {result.file.path, _cropSource?.path ?? ''});
await _flight(result.file);
}
Future<void> _openDraw() async {
@@ -151,37 +189,36 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
showCustomNotification(context, 'Не удалось открыть редактор');
return;
}
final result = await _pushEditor<File>(
PhotoDrawEditor(source: file, imageWidth: dims.$1, imageHeight: dims.$2),
await _pushEditor<File>(
() => PhotoDrawEditor(
source: file,
imageWidth: dims.$1,
imageHeight: dims.$2,
onPreview: _applyBaked,
),
);
if (result != null && mounted) {
final oldWorking = _workingFile;
final oldCropSource = _cropSource;
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _setWorkingFile(result));
_reportEdit();
_disposeTemp(oldWorking, {result.path});
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
}
}
Future<void> _openAdjust() async {
final file = _workingFile;
if (file == null) return;
final result = await _pushEditor<File>(PhotoAdjustEditor(source: file));
if (result != null && mounted) {
final oldWorking = _workingFile;
final oldCropSource = _cropSource;
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _setWorkingFile(result));
_reportEdit();
_disposeTemp(oldWorking, {result.path});
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
}
await _pushEditor<File>(
() => PhotoAdjustEditor(source: file, onPreview: _applyBaked),
);
}
Future<void> _applyBaked(File result) async {
if (!mounted) return;
final oldWorking = _workingFile;
final oldCropSource = _cropSource;
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _setWorkingFile(result));
_reportEdit();
_disposeTemp(oldWorking, {result.path});
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
await _flight(result);
}
@override
@@ -205,7 +242,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
actions: [
Padding(
padding: const EdgeInsets.only(right: 14),
child: _SelectionToggle(
child: PreviewSelectionToggle(
selectedIds: widget.selectedIds,
id: widget.item.id,
onTap: widget.onToggleSelection,
@@ -222,7 +259,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
minScale: 1,
maxScale: 4,
transformationController: _zoom,
child: _buildImage(),
child: KeyedSubtree(key: _stageKey, child: _buildImage()),
),
),
),
@@ -269,7 +306,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
Widget _buildCaptionField() {
return Container(
decoration: BoxDecoration(
color: _kBar,
color: kEditorBar,
borderRadius: BorderRadius.circular(28),
),
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
@@ -293,7 +330,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
valueListenable: widget.selectedIds,
builder: (context, selected, _) {
final count = selected.isEmpty ? 1 : selected.length;
return _CountBadge(count: count);
return PreviewCountBadge(count: count);
},
),
],
@@ -308,193 +345,23 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
child: Container(
height: 52,
decoration: BoxDecoration(
color: _kBar,
color: kEditorBar,
borderRadius: BorderRadius.circular(28),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_ToolIcon(icon: Symbols.crop_rotate, onTap: _openCrop),
_ToolIcon(icon: Symbols.brush, onTap: _openDraw),
const _FileToggle(),
_ToolIcon(icon: Symbols.tune, onTap: _openAdjust),
PreviewToolIcon(icon: Symbols.crop_rotate, onTap: _openCrop),
PreviewToolIcon(icon: Symbols.brush, onTap: _openDraw),
const PreviewFileToggle(),
PreviewToolIcon(icon: Symbols.tune, onTap: _openAdjust),
],
),
),
),
const SizedBox(width: 10),
_SendButton(onTap: _send),
PreviewSendButton(onTap: _send),
],
);
}
}
class _SelectionToggle extends StatelessWidget {
final ValueListenable<Set<String>> selectedIds;
final String id;
final VoidCallback onTap;
const _SelectionToggle({
required this.selectedIds,
required this.id,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Set<String>>(
valueListenable: selectedIds,
builder: (context, selected, _) {
final index = selected.toList().indexOf(id);
final isSelected = index >= 0;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? MediaAccent.of(context) : Colors.transparent,
border: Border.all(color: Colors.white, width: 2),
),
child: isSelected
? Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
height: 1.0,
),
)
: null,
),
);
},
);
}
}
class _CountBadge extends StatelessWidget {
final int count;
const _CountBadge({required this.count});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: const _DashedCirclePainter(color: Colors.white),
child: SizedBox(
width: 34,
height: 34,
child: Center(
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
class _DashedCirclePainter extends CustomPainter {
final Color color;
const _DashedCirclePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round;
final rect = Rect.fromLTWH(1.5, 1.5, size.width - 3, size.height - 3);
const dashes = 22;
const sweep = (2 * math.pi) / dashes;
const dashRatio = 0.55;
for (var i = 0; i < dashes; i++) {
canvas.drawArc(rect, i * sweep, sweep * dashRatio, false, paint);
}
}
@override
bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) =>
oldDelegate.color != color;
}
class _ToolIcon extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _ToolIcon({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: Colors.white, size: 24),
);
}
}
class _FileToggle extends StatefulWidget {
const _FileToggle();
@override
State<_FileToggle> createState() => _FileToggleState();
}
class _FileToggleState extends State<_FileToggle> {
bool _active = false;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => setState(() => _active = !_active),
icon: TweenAnimationBuilder<double>(
tween: Tween(end: _active ? 1 : 0),
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
builder: (context, t, _) {
final color = Color.lerp(
Colors.white54,
Color.lerp(Colors.white, MediaAccent.of(context), 0.4),
t,
);
return Icon(Symbols.description, color: color, size: 24);
},
),
);
}
}
class _SendButton extends StatelessWidget {
final VoidCallback onTap;
const _SendButton({required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: MediaAccent.of(context),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: const SizedBox(
width: 52,
height: 52,
child: Icon(Symbols.send, color: Colors.white, size: 24, fill: 1),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+64 -15
View File
@@ -1,5 +1,7 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
typedef PhotoHeroOrigin = Rect? Function();
@@ -20,6 +22,9 @@ Rect? _globalRect(RenderObject? object) {
);
}
Rect inscribeRect(Size source, Rect box, {bool cover = false}) =>
_inscribe(source, box, cover: cover);
Rect _inscribe(Size source, Rect box, {required bool cover}) {
if (source.isEmpty || box.isEmpty) return box;
final scaleX = box.width / source.width;
@@ -67,13 +72,12 @@ class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, animation, secondaryAnimation) =>
PhotoHeroScope(controller: hero, child: builder(context)),
transitionsBuilder:
(context, animation, secondaryAnimation, child) =>
_PhotoHeroTransition(
controller: hero,
animation: animation,
child: child,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_PhotoHeroTransition(
controller: hero,
animation: animation,
child: child,
),
);
final PhotoHeroController hero;
@@ -107,22 +111,67 @@ class PhotoHeroTarget extends StatelessWidget {
final Widget child;
@override
Widget build(BuildContext context) =>
PhotoHeroAnchor(child: PhotoHeroFade(child: child));
}
class PhotoHeroAnchor extends StatelessWidget {
const PhotoHeroAnchor({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final controller = PhotoHeroScope.maybeOf(context);
if (controller == null) return child;
return KeyedSubtree(
key: controller.areaKey,
child: ValueListenableBuilder<bool>(
valueListenable: controller.flying,
child: child,
builder: (context, flying, child) =>
flying ? Opacity(opacity: 0, child: child) : child!,
),
return KeyedSubtree(key: controller.areaKey, child: child);
}
}
class PhotoHeroFade extends StatelessWidget {
const PhotoHeroFade({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final controller = PhotoHeroScope.maybeOf(context);
if (controller == null) return child;
return ValueListenableBuilder<bool>(
valueListenable: controller.flying,
child: child,
builder: (context, flying, child) =>
flying ? Opacity(opacity: 0, child: child) : child!,
);
}
}
class RawImageProvider extends ImageProvider<RawImageProvider> {
const RawImageProvider(this.image);
final ui.Image image;
@override
Future<RawImageProvider> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<RawImageProvider>(this);
@override
ImageStreamCompleter loadImage(
RawImageProvider key,
ImageDecoderCallback decode,
) => OneFrameImageStreamCompleter(
SynchronousFuture<ImageInfo>(ImageInfo(image: image.clone())),
);
@override
bool operator ==(Object other) =>
other is RawImageProvider && identical(other.image, image);
@override
int get hashCode => identityHashCode(image);
}
class _PhotoHeroTransition extends StatefulWidget {
const _PhotoHeroTransition({
required this.controller,
@@ -0,0 +1,178 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_colors.dart';
class PreviewSelectionToggle extends StatelessWidget {
final ValueListenable<Set<String>> selectedIds;
final String id;
final VoidCallback onTap;
const PreviewSelectionToggle({
super.key,
required this.selectedIds,
required this.id,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Set<String>>(
valueListenable: selectedIds,
builder: (context, selected, _) {
final index = selected.toList().indexOf(id);
final isSelected = index >= 0;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? MediaAccent.of(context) : Colors.transparent,
border: Border.all(color: Colors.white, width: 2),
),
child: isSelected
? Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
height: 1.0,
),
)
: null,
),
);
},
);
}
}
class PreviewCountBadge extends StatelessWidget {
final int count;
const PreviewCountBadge({super.key, required this.count});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: const _DashedCirclePainter(color: Colors.white),
child: SizedBox(
width: 34,
height: 34,
child: Center(
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
class _DashedCirclePainter extends CustomPainter {
final Color color;
const _DashedCirclePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round;
final rect = Rect.fromLTWH(1.5, 1.5, size.width - 3, size.height - 3);
const dashes = 22;
const sweep = (2 * math.pi) / dashes;
const dashRatio = 0.55;
for (var i = 0; i < dashes; i++) {
canvas.drawArc(rect, i * sweep, sweep * dashRatio, false, paint);
}
}
@override
bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) =>
oldDelegate.color != color;
}
class PreviewToolIcon extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const PreviewToolIcon({super.key, required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: Colors.white, size: 24),
);
}
}
class PreviewFileToggle extends StatefulWidget {
const PreviewFileToggle({super.key});
@override
State<PreviewFileToggle> createState() => _FileToggleState();
}
class _FileToggleState extends State<PreviewFileToggle> {
bool _active = false;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => setState(() => _active = !_active),
icon: TweenAnimationBuilder<double>(
tween: Tween(end: _active ? 1 : 0),
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
builder: (context, t, _) {
final color = Color.lerp(
Colors.white54,
Color.lerp(Colors.white, MediaAccent.of(context), 0.4),
t,
);
return Icon(Symbols.description, color: color, size: 24);
},
),
);
}
}
class PreviewSendButton extends StatelessWidget {
final VoidCallback onTap;
const PreviewSendButton({super.key, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: MediaAccent.of(context),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: const SizedBox(
width: 52,
height: 52,
child: Icon(Symbols.send, color: Colors.white, size: 24, fill: 1),
),
),
);
}
}
@@ -0,0 +1,300 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../../core/media/video_transcoder.dart';
import 'editor_common.dart';
const List<int> kVideoQualitySteps = [144, 240, 360, 480, 720, 1080];
const Duration kMinTrimDuration = Duration(milliseconds: 800);
class VideoCropEdit {
final CropState state;
final Size viewport;
const VideoCropEdit({required this.state, required this.viewport});
}
class VideoEditState {
Duration start = Duration.zero;
Duration end = Duration.zero;
bool muted = false;
VideoCropEdit? crop;
List<EditMark> marks = [];
Size marksCanvas = Size.zero;
ColorAdjust adjust = ColorAdjust();
int? maxShortSide;
File? exported;
String? exportedSignature;
bool get trimmed =>
start > Duration.zero ||
(sourceDuration > Duration.zero && end < sourceDuration);
Duration sourceDuration = Duration.zero;
Duration get duration {
final span = end - start;
return span > Duration.zero ? span : Duration.zero;
}
bool get hasEdits =>
trimmed ||
muted ||
crop != null ||
marks.isNotEmpty ||
!adjust.pristine ||
maxShortSide != null;
String signature(Size source) {
final geometry = VideoGeometry.resolve(crop, source);
final out = geometry.outputSize(maxShortSide);
final m = adjust.matrix().map((v) => v.toStringAsFixed(3)).join(',');
return [
start.inMilliseconds,
end.inMilliseconds,
muted,
geometry.rotationDegrees.toStringAsFixed(3),
geometry.flipH,
geometry.cropNorm,
out,
m,
marks.length,
marksCanvas,
_marksFingerprint(),
].join('|');
}
String _marksFingerprint() {
final buffer = StringBuffer();
for (final mark in marks) {
switch (mark) {
case StrokeMark s:
buffer.write('s${s.points.length}${s.tool.index}${s.width}');
case ShapeMark sh:
buffer.write('h${sh.kind.index}${sh.start}${sh.end}');
case TextMark t:
buffer.write('t${t.text}${t.position}${t.fontSize}${t.rotation}');
}
}
return buffer.toString();
}
}
class VideoGeometry {
final Size source;
final double phi;
final bool flipH;
final Rect cropNorm;
const VideoGeometry({
required this.source,
required this.phi,
required this.flipH,
required this.cropNorm,
});
static VideoGeometry resolve(VideoCropEdit? edit, Size source) {
if (edit == null || source.isEmpty) {
return VideoGeometry(
source: source,
phi: 0,
flipH: false,
cropNorm: const Rect.fromLTRB(0, 0, 1, 1),
);
}
final state = edit.state;
final geometry = CropGeometry(
source: source,
quarterTurns: state.quarterTurns,
flipH: state.flipH,
straightenDeg: state.straightenDeg,
);
final vp = edit.viewport;
final rect = Rect.fromLTRB(
state.cropNorm.left * vp.width,
state.cropNorm.top * vp.height,
state.cropNorm.right * vp.width,
state.cropNorm.bottom * vp.height,
);
return VideoGeometry(
source: source,
phi: geometry.phi,
flipH: state.flipH,
cropNorm: geometry.cropInRotated(vp, rect),
);
}
double get rotationDegrees {
final degrees = phi * 180 / math.pi;
return flipH ? degrees : -degrees;
}
VideoGeometry withSource(Size other) =>
VideoGeometry(source: other, phi: phi, flipH: flipH, cropNorm: cropNorm);
Size get rotatedSize {
final c = math.cos(phi).abs();
final s = math.sin(phi).abs();
return Size(
source.width * c + source.height * s,
source.width * s + source.height * c,
);
}
Size get naturalOutput {
final r = rotatedSize;
return Size(cropNorm.width * r.width, cropNorm.height * r.height);
}
Size outputSize(int? maxShortSide) {
var w = naturalOutput.width;
var h = naturalOutput.height;
if (w <= 0 || h <= 0) return const Size(2, 2);
final short = math.min(w, h);
if (maxShortSide != null && short > maxShortSide) {
final k = maxShortSide / short;
w *= k;
h *= k;
}
return Size(_even(w), _even(h));
}
static double _even(double value) =>
math.max(2, (value / 2).round() * 2).toDouble();
Matrix4 sourceToOutput() {
final r = rotatedSize;
return Matrix4.identity()
..translateByDouble(
r.width / 2 - cropNorm.left * r.width,
r.height / 2 - cropNorm.top * r.height,
0,
1,
)
..multiply(flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity())
..rotateZ(phi)
..translateByDouble(-source.width / 2, -source.height / 2, 0, 1);
}
}
List<double>? glColorMatrix(ColorAdjust adjust) {
if (adjust.colorPristine) return null;
final m = adjust.matrix();
return [
m[0],
m[5],
m[10],
0,
m[1],
m[6],
m[11],
0,
m[2],
m[7],
m[12],
0,
m[4] / 255,
m[9] / 255,
m[14] / 255,
1,
];
}
List<int> videoQualityOptions(int naturalShortSide) {
final options = kVideoQualitySteps
.where((step) => step < naturalShortSide)
.toList();
options.add(naturalShortSide);
return options;
}
int estimateVideoBitrate(Size output, double fps) {
final rate = output.width * output.height * (fps <= 0 ? 30 : fps) * 0.09;
return rate.round().clamp(300000, 12000000);
}
int estimateVideoSizeBytes(Size output, double fps, Duration duration) {
final bitrate = estimateVideoBitrate(output, fps);
return (bitrate * duration.inMilliseconds / 8000).round();
}
Future<File?> bakeVideoOverlay(VideoEditState edit, Size output) async {
if (edit.marks.isEmpty && edit.adjust.vignette <= 0) return null;
final width = output.width.round();
final height = output.height.round();
if (width <= 0 || height <= 0) return null;
try {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final rect = Rect.fromLTWH(0, 0, output.width, output.height);
if (edit.adjust.vignette > 0) {
canvas.drawRect(
rect,
Paint()..shader = edit.adjust.vignetteGradient().createShader(rect),
);
}
if (edit.marks.isNotEmpty && !edit.marksCanvas.isEmpty) {
canvas.save();
canvas.scale(
output.width / edit.marksCanvas.width,
output.height / edit.marksCanvas.height,
);
DrawingPainter(marks: edit.marks).paintMarks(canvas, edit.marksCanvas);
canvas.restore();
}
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
final data = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (data == null) return null;
final dir = await getTemporaryDirectory();
final file = File(
p.join(
dir.path,
'komet_vov_${DateTime.now().microsecondsSinceEpoch}.png',
),
);
await file.writeAsBytes(data.buffer.asUint8List());
return file;
} catch (_) {
return null;
}
}
Future<VideoExportSpec?> buildVideoExportSpec(
VideoEditState edit,
String input,
Size source,
double fps,
) async {
final output = await VideoTranscoder.outputFile('video');
if (output == null) return null;
final geometry = VideoGeometry.resolve(edit.crop, source);
final size = geometry.outputSize(edit.maxShortSide);
final overlay = await bakeVideoOverlay(edit, size);
final full = geometry.cropNorm == const Rect.fromLTRB(0, 0, 1, 1);
return VideoExportSpec(
input: input,
output: output.path,
startMs: edit.start.inMilliseconds,
endMs: edit.end > Duration.zero ? edit.end.inMilliseconds : null,
removeAudio: edit.muted,
rotationDegrees: geometry.rotationDegrees,
flipH: geometry.flipH,
crop: full ? null : geometry.cropNorm,
outWidth: size.width.round(),
outHeight: size.height.round(),
rgbMatrix: glColorMatrix(edit.adjust),
overlayPath: overlay?.path,
bitrate: estimateVideoBitrate(size, fps),
);
}
@@ -0,0 +1,659 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../small_spinner.dart';
import 'editor_common.dart';
import 'photo_hero.dart';
import 'video_edit.dart';
class VideoStill extends StatelessWidget {
final ui.Image frame;
final VideoGeometry geometry;
final ColorAdjust adjust;
final List<EditMark> marks;
final Size marksCanvas;
const VideoStill({
super.key,
required this.frame,
required this.geometry,
required this.adjust,
this.marks = const [],
this.marksCanvas = Size.zero,
});
@override
Widget build(BuildContext context) {
final local = geometry.withSource(
Size(frame.width.toDouble(), frame.height.toDouble()),
);
return ColorFiltered(
colorFilter: ColorFilter.matrix(adjust.matrix()),
child: Stack(
fit: StackFit.expand,
children: [
CustomPaint(painter: _StillPainter(frame, local)),
if (adjust.vignette > 0)
DecoratedBox(
decoration: BoxDecoration(gradient: adjust.vignetteGradient()),
),
if (marks.isNotEmpty && !marksCanvas.isEmpty)
FittedBox(
fit: BoxFit.fill,
child: SizedBox(
width: marksCanvas.width,
height: marksCanvas.height,
child: CustomPaint(painter: DrawingPainter(marks: marks)),
),
),
],
),
);
}
}
class _StillPainter extends CustomPainter {
final ui.Image frame;
final VideoGeometry geometry;
_StillPainter(this.frame, this.geometry);
@override
void paint(Canvas canvas, Size size) {
final out = geometry.naturalOutput;
if (out.isEmpty || size.isEmpty) return;
canvas.clipRect(Offset.zero & size);
canvas.save();
canvas.scale(size.width / out.width, size.height / out.height);
canvas.transform(geometry.sourceToOutput().storage);
canvas.drawImage(
frame,
Offset.zero,
Paint()..filterQuality = FilterQuality.medium,
);
canvas.restore();
}
@override
bool shouldRepaint(covariant _StillPainter old) =>
old.frame != frame || old.geometry != geometry;
}
Future<ui.Image?> composeVideoStill(
ui.Image frame,
VideoGeometry geometry,
ColorAdjust adjust, {
List<EditMark> marks = const [],
Size marksCanvas = Size.zero,
}) async {
final local = geometry.withSource(
Size(frame.width.toDouble(), frame.height.toDouble()),
);
final out = local.naturalOutput;
final width = out.width.round();
final height = out.height.round();
if (width <= 0 || height <= 0) return null;
try {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final rect = Rect.fromLTWH(0, 0, out.width, out.height);
canvas.saveLayer(
rect,
Paint()..colorFilter = ColorFilter.matrix(adjust.matrix()),
);
canvas.save();
canvas.clipRect(rect);
canvas.transform(local.sourceToOutput().storage);
canvas.drawImage(
frame,
Offset.zero,
Paint()..filterQuality = FilterQuality.medium,
);
canvas.restore();
canvas.restore();
if (adjust.vignette > 0) {
canvas.drawRect(
rect,
Paint()..shader = adjust.vignetteGradient().createShader(rect),
);
}
if (marks.isNotEmpty && !marksCanvas.isEmpty) {
canvas.save();
canvas.scale(
out.width / marksCanvas.width,
out.height / marksCanvas.height,
);
DrawingPainter(marks: marks).paintMarks(canvas, marksCanvas);
canvas.restore();
}
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
picture.dispose();
return image;
} catch (_) {
return null;
}
}
class VideoCropEditor extends StatelessWidget {
final ui.Image frame;
final ColorAdjust adjust;
final VideoCropEdit? initial;
final Future<void> Function(VideoCropResult result)? onPreview;
const VideoCropEditor({
super.key,
required this.frame,
required this.adjust,
this.initial,
this.onPreview,
});
Future<Object?> _apply(
CropState state,
Size viewport,
bool changed,
bool identity,
) async {
if (!changed && !identity) return null;
final result = identity
? const VideoCropResult(null)
: VideoCropResult(VideoCropEdit(state: state, viewport: viewport));
await onPreview?.call(result);
return result;
}
@override
Widget build(BuildContext context) {
return CropWorkspace(
imageSize: Size(frame.width.toDouble(), frame.height.toDouble()),
initialState: initial?.state,
onApply: _apply,
imageBuilder: (context, matrix) => ColorFiltered(
colorFilter: ColorFilter.matrix(adjust.matrix()),
child: CustomPaint(painter: MatrixImagePainter(frame, matrix)),
),
);
}
}
class VideoCropResult {
final VideoCropEdit? crop;
const VideoCropResult(this.crop);
}
class VideoMarksResult {
final List<EditMark> marks;
final Size canvas;
const VideoMarksResult(this.marks, this.canvas);
}
class VideoDrawEditor extends StatelessWidget {
final ui.Image frame;
final VideoGeometry geometry;
final ColorAdjust adjust;
final List<EditMark> initialMarks;
final Future<void> Function(VideoMarksResult result)? onPreview;
const VideoDrawEditor({
super.key,
required this.frame,
required this.geometry,
required this.adjust,
this.initialMarks = const [],
this.onPreview,
});
@override
Widget build(BuildContext context) {
final out = geometry.naturalOutput;
return MarkupEditor(
aspectRatio: out.height > 0 ? out.width / out.height : 1.0,
initialMarks: initialMarks,
background: VideoStill(frame: frame, geometry: geometry, adjust: adjust),
onApply: (marks, canvas) async {
final result = VideoMarksResult([...marks], canvas);
await onPreview?.call(result);
return result;
},
);
}
}
class VideoAdjustEditor extends StatefulWidget {
final ui.Image frame;
final VideoGeometry geometry;
final ColorAdjust initial;
final List<EditMark> marks;
final Size marksCanvas;
final Future<void> Function(ColorAdjust result)? onPreview;
const VideoAdjustEditor({
super.key,
required this.frame,
required this.geometry,
required this.initial,
this.marks = const [],
this.marksCanvas = Size.zero,
this.onPreview,
});
@override
State<VideoAdjustEditor> createState() => _VideoAdjustEditorState();
}
class _VideoAdjustEditorState extends State<VideoAdjustEditor> {
late final ColorAdjust _adjust = widget.initial.copy();
final ValueNotifier<int> _rev = ValueNotifier(0);
bool _busy = false;
Future<void> _done() async {
if (_busy) return;
setState(() => _busy = true);
await widget.onPreview?.call(_adjust);
if (!mounted) return;
Navigator.of(context).pop(_adjust);
}
@override
void dispose() {
_rev.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final out = widget.geometry.naturalOutput;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
Column(
children: [
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: out.height > 0
? out.width / out.height
: 1.0,
child: PhotoHeroTarget(
child: ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) => VideoStill(
frame: widget.frame,
geometry: widget.geometry,
adjust: _adjust,
marks: widget.marks,
marksCanvas: widget.marksCanvas,
),
),
),
),
),
),
ValueListenableBuilder<int>(
valueListenable: _rev,
builder: (context, _, _) => AdjustSliders(
adjust: _adjust,
onChanged: () => _rev.value++,
),
),
Container(
color: kEditorPanel,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
l10n.photoEditorCancel,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
),
),
),
const Spacer(),
Icon(Symbols.tune, color: MediaAccent.of(context)),
const Spacer(),
TextButton(
onPressed: _busy ? null : _done,
child: Text(
l10n.photoEditorDone,
style: TextStyle(
color: _busy
? Colors.white38
: MediaAccent.of(context),
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
if (_busy) const BusyOverlay(),
],
),
),
);
}
}
class VideoQualityEditor extends StatefulWidget {
final ui.Image frame;
final VideoGeometry geometry;
final ColorAdjust adjust;
final List<EditMark> marks;
final Size marksCanvas;
final List<int> options;
final int selected;
final double fps;
final Duration duration;
final String? title;
final Future<void> Function(int result)? onPreview;
const VideoQualityEditor({
super.key,
required this.frame,
required this.geometry,
required this.adjust,
required this.options,
required this.selected,
this.marks = const [],
this.marksCanvas = Size.zero,
required this.fps,
required this.duration,
this.title,
this.onPreview,
});
@override
State<VideoQualityEditor> createState() => _VideoQualityEditorState();
}
class _VideoQualityEditorState extends State<VideoQualityEditor> {
late int _index = math.max(0, widget.options.indexOf(widget.selected));
bool _busy = false;
Future<void> _done() async {
if (_busy) return;
final value = widget.options[_index];
setState(() => _busy = true);
await widget.onPreview?.call(value);
if (!mounted) return;
Navigator.of(context).pop(value);
}
Size get _outputSize => widget.geometry.outputSize(widget.options[_index]);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final out = widget.geometry.naturalOutput;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
surfaceTintColor: Colors.transparent,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Symbols.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: VideoHeaderTitle(
title: widget.title,
size: _outputSize,
duration: widget.duration,
bytes: estimateVideoSizeBytes(
_outputSize,
widget.fps,
widget.duration,
),
),
),
body: Stack(
children: [
Column(
children: [
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: out.height > 0 ? out.width / out.height : 1.0,
child: PhotoHeroTarget(
child: VideoStill(
frame: widget.frame,
geometry: widget.geometry,
adjust: widget.adjust,
marks: widget.marks,
marksCanvas: widget.marksCanvas,
),
),
),
),
),
SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.videoEditorQualityLow,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
Text(
l10n.videoEditorQualityHigh,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
_QualitySlider(
count: widget.options.length,
index: _index,
onChanged: (value) => setState(() => _index = value),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
l10n.photoEditorCancel,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
),
),
),
TextButton(
onPressed: _busy ? null : _done,
child: Text(
l10n.photoEditorDone,
style: TextStyle(
color: _busy
? Colors.white38
: MediaAccent.of(context),
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
],
),
if (_busy) const BusyOverlay(),
],
),
);
}
}
class _QualitySlider extends StatelessWidget {
final int count;
final int index;
final ValueChanged<int> onChanged;
const _QualitySlider({
required this.count,
required this.index,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final accent = MediaAccent.of(context);
return SizedBox(
height: 40,
child: LayoutBuilder(
builder: (context, constraints) {
const inset = 16.0;
final span = math.max(1.0, constraints.maxWidth - inset * 2);
void pick(double dx) {
if (count <= 1) return;
final t = ((dx - inset) / span).clamp(0.0, 1.0);
final next = (t * (count - 1)).round();
if (next != index) onChanged(next);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (d) => pick(d.localPosition.dx),
onHorizontalDragUpdate: (d) => pick(d.localPosition.dx),
child: CustomPaint(
painter: _QualitySliderPainter(
count: count,
index: index,
accent: accent,
inset: inset,
),
),
);
},
),
);
}
}
class _QualitySliderPainter extends CustomPainter {
final int count;
final int index;
final Color accent;
final double inset;
_QualitySliderPainter({
required this.count,
required this.index,
required this.accent,
required this.inset,
});
@override
void paint(Canvas canvas, Size size) {
final y = size.height / 2;
final left = inset;
final right = size.width - inset;
final track = Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round;
canvas.drawLine(
Offset(left, y),
Offset(right, y),
track..color = Colors.white24,
);
final step = count <= 1 ? 0.0 : (right - left) / (count - 1);
final active = left + step * index;
canvas.drawLine(Offset(left, y), Offset(active, y), track..color = accent);
for (var i = 0; i < count; i++) {
final x = left + step * i;
canvas.drawCircle(
Offset(x, y),
4,
Paint()..color = i <= index ? accent : Colors.white38,
);
}
canvas.drawCircle(Offset(active, y), 9, Paint()..color = accent);
}
@override
bool shouldRepaint(covariant _QualitySliderPainter old) =>
old.index != index || old.count != count || old.accent != accent;
}
class VideoHeaderTitle extends StatelessWidget {
final String? title;
final Size size;
final Duration duration;
final int bytes;
const VideoHeaderTitle({
super.key,
required this.size,
required this.duration,
required this.bytes,
this.title,
});
@override
Widget build(BuildContext context) {
final name = title;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (name != null && name.isNotEmpty)
Text(
name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
Text(
'${size.width.round()}x${size.height.round()}, '
'${_duration(duration)}, ~${_bytes(bytes)}',
style: const TextStyle(fontSize: 13, color: Colors.white70),
overflow: TextOverflow.ellipsis,
),
],
);
}
static String _duration(Duration value) {
final minutes = value.inMinutes;
final seconds = value.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
static String _bytes(int value) {
if (value >= 1024 * 1024) {
return '${(value / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(value / 1024).toStringAsFixed(1)} KB';
}
}
@@ -0,0 +1,994 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart';
import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/media/video_transcoder.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/lottie_slash_icon.dart';
import '../../../core/config/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../small_spinner.dart';
import 'editor_common.dart';
import 'photo_hero.dart';
import 'preview_chrome.dart';
import 'video_edit.dart';
import 'video_editor.dart';
const int _kStripFrames = 12;
class VideoPreviewScreen extends StatefulWidget {
final GalleryItem item;
final PhotoHeroController hero;
final String? title;
final ValueListenable<Set<String>> selectedIds;
final VoidCallback onToggleSelection;
final VoidCallback onSend;
final VideoEditState edit;
final bool editable;
final VoidCallback? onEditChanged;
final String initialCaption;
final ValueChanged<String>? onCaptionChanged;
const VideoPreviewScreen({
super.key,
required this.item,
required this.hero,
required this.selectedIds,
required this.onToggleSelection,
required this.onSend,
required this.edit,
this.editable = true,
this.title,
this.onEditChanged,
this.initialCaption = '',
this.onCaptionChanged,
});
@override
State<VideoPreviewScreen> createState() => _VideoPreviewScreenState();
}
class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
late final TextEditingController _caption = TextEditingController(
text: widget.initialCaption,
);
final GlobalKey _stageKey = GlobalKey();
final List<ui.Image> _flightImages = [];
PhotoHeroController? _activeHero;
ui.Image? _editorFrame;
File? _file;
VideoInfo? _info;
VideoPlayerController? _controller;
List<Uint8List?> _strip = const [];
int _sourceBytes = 0;
bool _busy = false;
bool _scrubbing = false;
VideoEditState get _edit => widget.edit;
@override
void initState() {
super.initState();
_caption.addListener(() => widget.onCaptionChanged?.call(_caption.text));
_load();
}
@override
void dispose() {
_releaseFlights();
_caption.dispose();
final controller = _controller;
_controller = null;
controller?.removeListener(_onTick);
controller?.dispose();
super.dispose();
}
Future<void> _load() async {
final file = widget.item.localFile ?? await widget.item.originFile();
if (file == null || !mounted) return;
_file = file;
_sourceBytes = await file.length().catchError((_) => 0);
_info = await VideoTranscoder.probe(file.path);
if (!mounted) return;
final controller = VideoPlayerController.file(file);
try {
await controller.initialize();
} catch (_) {
controller.dispose();
if (mounted) {
showCustomNotification(
context,
AppLocalizations.of(context)!.videoEditorFrameFailed,
);
}
return;
}
if (!mounted) {
controller.dispose();
return;
}
final duration = _duration(controller);
if (_edit.end <= Duration.zero) {
_edit.sourceDuration = duration;
_edit.end = duration;
}
controller.addListener(_onTick);
await controller.setVolume(_edit.muted ? 0 : 1);
setState(() => _controller = controller);
unawaited(controller.play());
unawaited(_loadStrip(file, duration));
}
Duration _duration(VideoPlayerController controller) {
final info = _info;
if (info != null && info.durationMs > 0) {
return Duration(milliseconds: info.durationMs);
}
return controller.value.duration;
}
Future<void> _loadStrip(File file, Duration duration) async {
if (duration <= Duration.zero) return;
final step = duration.inMilliseconds / _kStripFrames;
final times = List<int>.generate(
_kStripFrames,
(i) => (step * (i + 0.5)).round(),
);
final frames = await VideoTranscoder.frames(file.path, times, size: 160);
if (!mounted) return;
setState(() => _strip = frames);
}
void _onTick() {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
if (_scrubbing) return;
final position = controller.value.position;
if (position >= _edit.end && _edit.end > Duration.zero) {
controller.seekTo(_edit.start);
if (!controller.value.isPlaying) unawaited(controller.play());
} else if (position < _edit.start - const Duration(milliseconds: 120)) {
controller.seekTo(_edit.start);
}
}
Size get _sourceSize {
final info = _info;
if (info != null && info.width > 0 && info.height > 0) {
return Size(info.width.toDouble(), info.height.toDouble());
}
final size = _controller?.value.size ?? Size.zero;
return size.isEmpty ? const Size(16, 9) : size;
}
double get _fps => _info?.fps ?? 30;
VideoGeometry get _geometry => VideoGeometry.resolve(_edit.crop, _sourceSize);
Size get _outputSize => _geometry.outputSize(_edit.maxShortSide);
int get _naturalShortSide {
final natural = _geometry.naturalOutput;
return math.max(2, math.min(natural.width, natural.height).round());
}
void _changed() {
setState(() {});
widget.onEditChanged?.call();
}
void _togglePlay() {
final controller = _controller;
if (controller == null) return;
if (controller.value.isPlaying) {
controller.pause();
} else {
if (controller.value.position >= _edit.end) {
controller.seekTo(_edit.start);
}
controller.play();
}
setState(() {});
}
void _toggleMute() {
_edit.muted = !_edit.muted;
_controller?.setVolume(_edit.muted ? 0 : 1);
_changed();
}
void _send() {
Navigator.of(context).pop();
widget.onSend();
}
Future<T?> _pushEditor<T>(
(ui.Image, ui.Image) prepared,
Widget Function() builder,
) async {
final (frame, flight) = prepared;
final hero = PhotoHeroController(
origin: () => photoHeroRect(_stageKey),
image: RawImageProvider(flight),
);
_activeHero = hero;
_editorFrame = frame;
_flightImages.add(flight);
try {
return await Navigator.of(
context,
).push<T>(PhotoHeroRoute<T>(hero: hero, builder: (_) => builder()));
} finally {
_activeHero = null;
_editorFrame = null;
frame.dispose();
_releaseFlights();
}
}
void _releaseFlights() {
if (_flightImages.isEmpty) return;
final images = List<ui.Image>.of(_flightImages);
_flightImages.clear();
for (final image in images) {
unawaited(RawImageProvider(image).evict());
}
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final image in images) {
image.dispose();
}
});
}
Future<void> _preview(
void Function() apply, {
required bool withMarks,
}) async {
apply();
if (!mounted) return;
setState(() {});
widget.onEditChanged?.call();
final frame = _editorFrame;
final hero = _activeHero;
if (frame == null || hero == null) return;
final image = await composeVideoStill(
frame,
_geometry,
_edit.adjust,
marks: withMarks ? _edit.marks : const [],
marksCanvas: withMarks ? _edit.marksCanvas : Size.zero,
);
if (image == null) return;
if (!mounted || !identical(_activeHero, hero)) {
image.dispose();
return;
}
_flightImages.add(image);
hero.image.value = RawImageProvider(image);
}
Future<ui.Image?> _grabFrame() async {
final file = _file;
if (file == null) return null;
final position = _controller?.value.position ?? _edit.start;
final frames = await VideoTranscoder.frames(
file.path,
[
position.inMilliseconds.clamp(
_edit.start.inMilliseconds,
math.max(_edit.start.inMilliseconds, _edit.end.inMilliseconds),
),
],
size: 1280,
precise: true,
);
final data = frames.isEmpty ? null : frames.first;
if (data == null) return null;
try {
final codec = await ui.instantiateImageCodec(data);
final frame = await codec.getNextFrame();
codec.dispose();
return frame.image;
} catch (_) {
return null;
}
}
Future<(ui.Image, ui.Image)?> _prepare({required bool withMarks}) async {
_controller?.pause();
setState(() => _busy = true);
final frame = await _grabFrame();
ui.Image? flight;
if (frame != null) {
flight = await composeVideoStill(
frame,
_geometry,
_edit.adjust,
marks: withMarks ? _edit.marks : const [],
marksCanvas: withMarks ? _edit.marksCanvas : Size.zero,
);
}
if (!mounted) {
frame?.dispose();
flight?.dispose();
return null;
}
setState(() => _busy = false);
if (frame == null || flight == null) {
frame?.dispose();
flight?.dispose();
showCustomNotification(
context,
AppLocalizations.of(context)!.videoEditorFrameFailed,
);
return null;
}
return (frame, flight);
}
Future<void> _openCrop() async {
final prepared = await _prepare(withMarks: false);
if (prepared == null) return;
await _pushEditor<VideoCropResult>(
prepared,
() => VideoCropEditor(
frame: prepared.$1,
adjust: _edit.adjust,
initial: _edit.crop,
onPreview: (result) =>
_preview(() => _edit.crop = result.crop, withMarks: true),
),
);
}
Future<void> _openDraw() async {
final prepared = await _prepare(withMarks: true);
if (prepared == null) return;
await _pushEditor<VideoMarksResult>(
prepared,
() => VideoDrawEditor(
frame: prepared.$1,
geometry: _geometry,
adjust: _edit.adjust,
initialMarks: _edit.marks,
onPreview: (result) => _preview(() {
_edit.marks = result.marks;
_edit.marksCanvas = result.canvas;
}, withMarks: true),
),
);
}
Future<void> _openAdjust() async {
final prepared = await _prepare(withMarks: true);
if (prepared == null) return;
await _pushEditor<ColorAdjust>(
prepared,
() => VideoAdjustEditor(
frame: prepared.$1,
geometry: _geometry,
initial: _edit.adjust,
marks: _edit.marks,
marksCanvas: _edit.marksCanvas,
onPreview: (result) =>
_preview(() => _edit.adjust = result, withMarks: true),
),
);
}
Future<void> _openQuality() async {
final prepared = await _prepare(withMarks: true);
if (prepared == null) return;
final natural = _naturalShortSide;
final options = videoQualityOptions(natural);
await _pushEditor<int>(
prepared,
() => VideoQualityEditor(
frame: prepared.$1,
geometry: _geometry,
adjust: _edit.adjust,
marks: _edit.marks,
marksCanvas: _edit.marksCanvas,
options: options,
selected: _edit.maxShortSide ?? natural,
fps: _fps,
duration: _edit.duration,
title: widget.title,
onPreview: (result) => _preview(
() => _edit.maxShortSide = result >= natural ? null : result,
withMarks: true,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
surfaceTintColor: Colors.transparent,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Symbols.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: VideoHeaderTitle(
title: widget.title,
size: _outputSize,
duration: _edit.duration,
bytes: _edit.hasEdits
? estimateVideoSizeBytes(_outputSize, _fps, _edit.duration)
: _sourceBytes,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 14),
child: PreviewSelectionToggle(
selectedIds: widget.selectedIds,
id: widget.item.id,
onTap: widget.onToggleSelection,
),
),
],
),
body: Stack(
children: [
Column(
children: [
Expanded(
child: PhotoHeroTarget(child: Center(child: _stage())),
),
_bottomBar(),
],
),
if (_busy) const BusyOverlay(),
],
),
);
}
Widget _stage() {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) {
return const SmallSpinner(size: 36, color: Colors.white24);
}
final output = _geometry.naturalOutput;
if (output.isEmpty) return const SizedBox.shrink();
return AspectRatio(
key: _stageKey,
aspectRatio: output.width / output.height,
child: GestureDetector(
onTap: _togglePlay,
behavior: HitTestBehavior.opaque,
child: LayoutBuilder(
builder: (context, constraints) {
final scale = constraints.maxWidth / output.width;
final matrix = Matrix4.diagonal3Values(scale, scale, 1)
..multiply(_geometry.sourceToOutput());
final source = _sourceSize;
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
ColorFiltered(
colorFilter: ColorFilter.matrix(_edit.adjust.matrix()),
child: OverflowBox(
alignment: Alignment.topLeft,
minWidth: 0,
minHeight: 0,
maxWidth: double.infinity,
maxHeight: double.infinity,
child: Transform(
alignment: Alignment.topLeft,
transform: matrix,
child: SizedBox(
width: source.width,
height: source.height,
child: VideoPlayer(controller),
),
),
),
),
if (_edit.adjust.vignette > 0)
IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: _edit.adjust.vignetteGradient(),
),
),
),
if (_edit.marks.isNotEmpty && !_edit.marksCanvas.isEmpty)
IgnorePointer(
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox(
width: _edit.marksCanvas.width,
height: _edit.marksCanvas.height,
child: CustomPaint(
painter: DrawingPainter(marks: _edit.marks),
),
),
),
),
if (!controller.value.isPlaying)
const IgnorePointer(child: Center(child: _PlayBadge())),
],
),
);
},
),
),
);
}
Widget _bottomBar() {
final controller = _controller;
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.editable) ...[
Row(
children: [
_MuteButton(muted: _edit.muted, onTap: _toggleMute),
const Spacer(),
],
),
const SizedBox(height: 4),
],
if (widget.editable &&
controller != null &&
controller.value.isInitialized)
TrimBar(
frames: _strip,
controller: controller,
duration: _edit.sourceDuration,
start: _edit.start,
end: _edit.end,
onScrub: _onScrub,
onTrim: _onTrim,
),
const SizedBox(height: 10),
_captionField(),
const SizedBox(height: 10),
_toolbar(),
],
),
),
);
}
void _onScrub(Duration position, bool active) {
_scrubbing = active;
final controller = _controller;
if (controller == null) return;
if (active && controller.value.isPlaying) controller.pause();
controller.seekTo(position);
}
void _onTrim(Duration start, Duration end, bool active) {
_scrubbing = active;
setState(() {
_edit.start = start;
_edit.end = end;
});
if (!active) widget.onEditChanged?.call();
}
Widget _captionField() {
final l10n = AppLocalizations.of(context)!;
return Container(
decoration: BoxDecoration(
color: kEditorBar,
borderRadius: BorderRadius.circular(28),
),
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _caption,
style: const TextStyle(color: Colors.white, fontSize: 15),
cursorColor: Colors.white,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: l10n.videoEditorCaptionHint,
hintStyle: const TextStyle(color: Colors.white54, fontSize: 15),
),
),
),
const SizedBox(width: 8),
ValueListenableBuilder<Set<String>>(
valueListenable: widget.selectedIds,
builder: (context, selected, _) => PreviewCountBadge(
count: selected.isEmpty ? 1 : selected.length,
),
),
],
),
);
}
Widget _toolbar() {
final ready = _controller?.value.isInitialized == true;
if (!widget.editable) {
return Row(
children: [
const Spacer(),
PreviewSendButton(onTap: _send),
],
);
}
return Row(
children: [
Expanded(
child: Container(
height: 52,
decoration: BoxDecoration(
color: kEditorBar,
borderRadius: BorderRadius.circular(28),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
PreviewToolIcon(
icon: Symbols.crop_rotate,
onTap: ready ? _openCrop : () {},
),
PreviewToolIcon(
icon: Symbols.brush,
onTap: ready ? _openDraw : () {},
),
_QualityBadge(
shortSide: _edit.maxShortSide ?? _naturalShortSide,
onTap: ready ? _openQuality : () {},
),
PreviewToolIcon(
icon: Symbols.tune,
onTap: ready ? _openAdjust : () {},
),
],
),
),
),
const SizedBox(width: 10),
PreviewSendButton(onTap: _send),
],
);
}
}
class _PlayBadge extends StatelessWidget {
const _PlayBadge();
@override
Widget build(BuildContext context) {
return Container(
width: 62,
height: 62,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.45),
),
child: const Icon(
Symbols.play_arrow,
color: Colors.white,
size: 34,
fill: 1,
),
);
}
}
class _MuteButton extends StatelessWidget {
final bool muted;
final VoidCallback onTap;
const _MuteButton({required this.muted, required this.onTap});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
tooltip: AppLocalizations.of(context)!.videoEditorMuteTooltip,
icon: LottieSlashIcon(
asset: 'assets/lottie/ic_volume_on_to_off.json',
slashed: muted,
color: muted ? MediaAccent.of(context) : Colors.white,
size: 26,
),
);
}
}
class _QualityBadge extends StatelessWidget {
final int shortSide;
final VoidCallback onTap;
const _QualityBadge({required this.shortSide, required this.onTap});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
tooltip: AppLocalizations.of(context)!.videoEditorQualityTooltip,
icon: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$shortSide',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
height: 1,
fontWeight: FontWeight.w700,
),
),
const Icon(Symbols.hd, color: Colors.white, size: 22, fill: 1),
],
),
);
}
}
class TrimBar extends StatefulWidget {
final List<Uint8List?> frames;
final VideoPlayerController controller;
final Duration duration;
final Duration start;
final Duration end;
final void Function(Duration position, bool active) onScrub;
final void Function(Duration start, Duration end, bool active) onTrim;
const TrimBar({
super.key,
required this.frames,
required this.controller,
required this.duration,
required this.start,
required this.end,
required this.onScrub,
required this.onTrim,
});
@override
State<TrimBar> createState() => _TrimBarState();
}
class _TrimBarState extends State<TrimBar> {
static const double _handle = 13;
static const double _height = 48;
int _target = -1;
double _fraction(Duration value) {
final total = widget.duration.inMilliseconds;
if (total <= 0) return 0;
return (value.inMilliseconds / total).clamp(0.0, 1.0);
}
Duration _at(double fraction) => Duration(
milliseconds: (fraction.clamp(0.0, 1.0) * widget.duration.inMilliseconds)
.round(),
);
void _down(Offset pos, double width) {
final startX = _fraction(widget.start) * width;
final endX = _fraction(widget.end) * width;
final toStart = (pos.dx - startX).abs();
final toEnd = (pos.dx - endX).abs();
if (toStart <= toEnd && toStart < 28) {
_target = 0;
} else if (toEnd < 28) {
_target = 1;
} else {
_target = 2;
widget.onScrub(_clamp(_at(pos.dx / width)), true);
}
}
Duration _clamp(Duration value) {
if (value < widget.start) return widget.start;
if (widget.end > Duration.zero && value > widget.end) return widget.end;
return value;
}
void _move(Offset pos, double width) {
if (width <= 0) return;
final value = _at(pos.dx / width);
switch (_target) {
case 0:
final limit = widget.end - kMinTrimDuration;
widget.onTrim(
value > limit
? (limit > Duration.zero ? limit : Duration.zero)
: value,
widget.end,
true,
);
widget.onScrub(value, true);
case 1:
final limit = widget.start + kMinTrimDuration;
widget.onTrim(widget.start, value < limit ? limit : value, true);
widget.onScrub(value < limit ? limit : value, true);
case 2:
widget.onScrub(_clamp(value), true);
}
}
void _up() {
if (_target < 0) return;
if (_target != 2) widget.onTrim(widget.start, widget.end, false);
widget.onScrub(_clamp(widget.controller.value.position), false);
_target = -1;
}
@override
Widget build(BuildContext context) {
final accent = MediaAccent.of(context);
return SizedBox(
height: _height,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final startX = _fraction(widget.start) * width;
final endX = _fraction(widget.end) * width;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => _down(d.localPosition, width),
onPanUpdate: (d) => _move(d.localPosition, width),
onPanEnd: (_) => _up(),
onPanCancel: _up,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Positioned.fill(child: _filmstrip()),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: math.max(0, startX),
child: const ColoredBox(color: Color(0x99000000)),
),
Positioned(
left: endX,
top: 0,
bottom: 0,
right: 0,
child: const ColoredBox(color: Color(0x99000000)),
),
Positioned(
left: startX,
right: math.max(0, width - endX),
top: 0,
bottom: 0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(color: accent, width: 2),
),
),
),
),
),
ValueListenableBuilder<VideoPlayerValue>(
valueListenable: widget.controller,
child: const IgnorePointer(child: _Playhead()),
builder: (context, value, child) => Positioned(
left: (_fraction(value.position) * width - 1.5).clamp(
0.0,
math.max(0, width - 3),
),
top: 2,
bottom: 2,
width: 3,
child: child!,
),
),
Positioned(
left: (startX - _handle).clamp(0.0, width),
top: 0,
bottom: 0,
width: _handle,
child: _Handle(color: accent, leading: true),
),
Positioned(
left: endX.clamp(0.0, math.max(0, width - _handle)),
top: 0,
bottom: 0,
width: _handle,
child: _Handle(color: accent, leading: false),
),
],
),
),
);
},
),
);
}
Widget _filmstrip() {
if (widget.frames.isEmpty) {
return const ColoredBox(color: Color(0xFF1E1E1E));
}
return Row(
children: [
for (final frame in widget.frames)
Expanded(
child: frame == null
? const ColoredBox(color: Color(0xFF1E1E1E))
: Image.memory(
frame,
fit: BoxFit.cover,
height: double.infinity,
gaplessPlayback: true,
),
),
],
);
}
}
class _Playhead extends StatelessWidget {
const _Playhead();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)],
),
);
}
}
class _Handle extends StatelessWidget {
final Color color;
final bool leading;
const _Handle({required this.color, required this.leading});
@override
Widget build(BuildContext context) {
final radius = leading
? const BorderRadius.horizontal(left: Radius.circular(8))
: const BorderRadius.horizontal(right: Radius.circular(8));
return DecoratedBox(
decoration: BoxDecoration(color: color, borderRadius: radius),
child: Center(
child: Container(
width: 2,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(1),
),
),
),
);
}
}
+9 -1
View File
@@ -1162,5 +1162,13 @@
}
},
"blacklistEmpty": "Nobody is blocked",
"blacklistLoadError": "Failed to load the blacklist"
"blacklistLoadError": "Failed to load the blacklist",
"videoEditorQualityLow": "Small size",
"videoEditorQualityHigh": "High quality",
"videoEditorCaptionHint": "Add a caption...",
"videoEditorMuteTooltip": "Send without sound",
"videoEditorProcessing": "Processing video…",
"videoEditorExportFailed": "Failed to process the video",
"videoEditorFrameFailed": "Failed to grab a frame",
"videoEditorQualityTooltip": "Quality"
}
+48
View File
@@ -5053,6 +5053,54 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Failed to load the blacklist'**
String get blacklistLoadError;
/// No description provided for @videoEditorQualityLow.
///
/// In en, this message translates to:
/// **'Small size'**
String get videoEditorQualityLow;
/// No description provided for @videoEditorQualityHigh.
///
/// In en, this message translates to:
/// **'High quality'**
String get videoEditorQualityHigh;
/// No description provided for @videoEditorCaptionHint.
///
/// In en, this message translates to:
/// **'Add a caption...'**
String get videoEditorCaptionHint;
/// No description provided for @videoEditorMuteTooltip.
///
/// In en, this message translates to:
/// **'Send without sound'**
String get videoEditorMuteTooltip;
/// No description provided for @videoEditorProcessing.
///
/// In en, this message translates to:
/// **'Processing video…'**
String get videoEditorProcessing;
/// No description provided for @videoEditorExportFailed.
///
/// In en, this message translates to:
/// **'Failed to process the video'**
String get videoEditorExportFailed;
/// No description provided for @videoEditorFrameFailed.
///
/// In en, this message translates to:
/// **'Failed to grab a frame'**
String get videoEditorFrameFailed;
/// No description provided for @videoEditorQualityTooltip.
///
/// In en, this message translates to:
/// **'Quality'**
String get videoEditorQualityTooltip;
}
class _AppLocalizationsDelegate
+24
View File
@@ -2652,4 +2652,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get blacklistLoadError => 'Failed to load the blacklist';
@override
String get videoEditorQualityLow => 'Small size';
@override
String get videoEditorQualityHigh => 'High quality';
@override
String get videoEditorCaptionHint => 'Add a caption...';
@override
String get videoEditorMuteTooltip => 'Send without sound';
@override
String get videoEditorProcessing => 'Processing video…';
@override
String get videoEditorExportFailed => 'Failed to process the video';
@override
String get videoEditorFrameFailed => 'Failed to grab a frame';
@override
String get videoEditorQualityTooltip => 'Quality';
}
+24
View File
@@ -2666,4 +2666,28 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get blacklistLoadError => 'Не удалось загрузить чёрный список';
@override
String get videoEditorQualityLow => 'Небольшой размер';
@override
String get videoEditorQualityHigh => 'Высокое качество';
@override
String get videoEditorCaptionHint => 'Добавить подпись...';
@override
String get videoEditorMuteTooltip => 'Отправить без звука';
@override
String get videoEditorProcessing => 'Обработка видео…';
@override
String get videoEditorExportFailed => 'Не удалось обработать видео';
@override
String get videoEditorFrameFailed => 'Не удалось получить кадр';
@override
String get videoEditorQualityTooltip => 'Качество';
}
+9 -1
View File
@@ -906,5 +906,13 @@
}
},
"blacklistEmpty": "Никто не заблокирован",
"blacklistLoadError": "Не удалось загрузить чёрный список"
"blacklistLoadError": "Не удалось загрузить чёрный список",
"videoEditorQualityLow": "Небольшой размер",
"videoEditorQualityHigh": "Высокое качество",
"videoEditorCaptionHint": "Добавить подпись...",
"videoEditorMuteTooltip": "Отправить без звука",
"videoEditorProcessing": "Обработка видео…",
"videoEditorExportFailed": "Не удалось обработать видео",
"videoEditorFrameFailed": "Не удалось получить кадр",
"videoEditorQualityTooltip": "Качество"
}