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
@@ -291,6 +291,32 @@ class MainActivity : FlutterActivity() {
cropSquare(input, output, size, result)
}
}
"probe" -> {
val input = call.argument<String>("input")
if (input == null) {
result.error("BAD_ARGS", "input required", null)
} else {
probeVideo(input, result)
}
}
"frames" -> {
val input = call.argument<String>("input")
val times = call.argument<List<Int>>("times")
if (input == null || times == null) {
result.error("BAD_ARGS", "input/times required", null)
} else {
videoFrames(
input,
times,
call.argument<Int>("size") ?: 256,
call.argument<Boolean>("precise") == true,
result,
)
}
}
"edit" -> editVideo(call, result)
"editProgress" -> editVideoProgress(result)
"editCancel" -> editVideoCancel(result)
else -> result.notImplemented()
}
}
@@ -502,6 +528,31 @@ class MainActivity : FlutterActivity() {
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun probeVideo(input: String, result: MethodChannel.Result) =
VideoEditor.probe(input, result)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun videoFrames(
input: String,
times: List<Int>,
size: Int,
precise: Boolean,
result: MethodChannel.Result,
) = VideoEditor.frames(input, times, size, precise, result)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun editVideo(call: MethodCall, result: MethodChannel.Result) =
VideoEditor.edit(this, call, result)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun editVideoProgress(result: MethodChannel.Result) =
VideoEditor.progress(result)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun editVideoCancel(result: MethodChannel.Result) =
VideoEditor.cancel(result)
private fun nfcStatus(): Map<String, Any> {
val adapter = nfcAdapter
return mapOf(
@@ -0,0 +1,352 @@
package ru.komet.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.media3.common.Effect
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.BitmapOverlay
import androidx.media3.effect.Crop
import androidx.media3.effect.OverlayEffect
import androidx.media3.effect.Presentation
import androidx.media3.effect.RgbMatrix
import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.media3.effect.TextureOverlay
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import java.io.File
@UnstableApi
object VideoEditor {
private const val LOG_TAG = "VideoEditor"
private val main = Handler(Looper.getMainLooper())
private val progressHolder = ProgressHolder()
private var transformer: Transformer? = null
private var overlayBitmap: Bitmap? = null
private var pending: MethodChannel.Result? = null
private class ColorMatrixEffect(private val values: FloatArray) : RgbMatrix {
override fun getMatrix(presentationTimeUs: Long, useHdr: Boolean) = values
}
fun probe(input: String, result: MethodChannel.Result) {
Thread {
val data = try {
readInfo(input)
} catch (e: Exception) {
Log.w(LOG_TAG, "probe failed: ${e.message}")
null
}
main.post { result.success(data) }
}.start()
}
private fun readInfo(input: String): Map<String, Any?>? {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(input)
val rotation = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0
val rawWidth = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull() ?: 0
val rawHeight = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull() ?: 0
val duration = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
if (rawWidth <= 0 || rawHeight <= 0) return null
val swap = rotation % 180 != 0
val track = readTrackInfo(input)
return mapOf(
"width" to if (swap) rawHeight else rawWidth,
"height" to if (swap) rawWidth else rawHeight,
"durationMs" to duration,
"fps" to track.first,
"hasAudio" to track.second,
)
} finally {
retriever.release()
}
}
private fun readTrackInfo(input: String): Pair<Double, Boolean> {
val extractor = MediaExtractor()
var fps = 30.0
var hasAudio = false
try {
extractor.setDataSource(input)
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) hasAudio = true
if (mime.startsWith("video/") &&
format.containsKey(MediaFormat.KEY_FRAME_RATE)
) {
fps = try {
format.getInteger(MediaFormat.KEY_FRAME_RATE).toDouble()
} catch (_: ClassCastException) {
format.getFloat(MediaFormat.KEY_FRAME_RATE).toDouble()
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "track info failed: ${e.message}")
} finally {
extractor.release()
}
return Pair(if (fps > 0) fps else 30.0, hasAudio)
}
fun frames(
input: String,
times: List<Int>,
size: Int,
precise: Boolean,
result: MethodChannel.Result,
) {
Thread {
val out = ArrayList<ByteArray?>(times.size)
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(input)
for (ms in times) {
out.add(grabFrame(retriever, ms.toLong(), size, precise))
}
} catch (e: Exception) {
Log.w(LOG_TAG, "frames failed: ${e.message}")
while (out.size < times.size) out.add(null)
} finally {
retriever.release()
}
main.post { result.success(out) }
}.start()
}
private fun grabFrame(
retriever: MediaMetadataRetriever,
timeMs: Long,
size: Int,
precise: Boolean,
): ByteArray? {
val us = timeMs * 1000L
val option = if (precise) {
MediaMetadataRetriever.OPTION_CLOSEST
} else {
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
}
val bitmap = (
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
retriever.getScaledFrameAtTime(us, option, size, size)
} else {
retriever.getFrameAtTime(us, option)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "frame at $timeMs failed: ${e.message}")
null
}
) ?: return null
return try {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 82, stream)
stream.toByteArray()
} finally {
bitmap.recycle()
}
}
fun edit(context: Context, call: MethodCall, result: MethodChannel.Result) {
val input = call.argument<String>("input")
val output = call.argument<String>("output")
if (input == null || output == null) {
result.error("BAD_ARGS", "input/output required", null)
return
}
release()
pending = result
try {
val effects = buildEffects(call)
val item = MediaItem.Builder()
.setUri(Uri.fromFile(File(input)))
.setClippingConfiguration(buildClipping(call))
.build()
val edited = EditedMediaItem.Builder(item)
.setRemoveAudio(call.argument<Boolean>("removeAudio") == true)
.setEffects(Effects(emptyList(), effects))
.build()
val builder = Transformer.Builder(context)
.addListener(object : Transformer.Listener {
override fun onCompleted(
composition: Composition,
exportResult: ExportResult,
) = finish(true)
override fun onError(
composition: Composition,
exportResult: ExportResult,
exportException: ExportException,
) {
Log.w(LOG_TAG, "export failed: ${exportException.message}")
finish(false)
}
})
val bitrate = call.argument<Int>("bitrate")
if (bitrate != null && bitrate > 0) {
builder.setEncoderFactory(
DefaultEncoderFactory.Builder(context)
.setRequestedVideoEncoderSettings(
VideoEncoderSettings.Builder()
.setBitrate(bitrate)
.setBitrateMode(
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR,
)
.build(),
)
.build(),
)
}
val transformer = builder.build()
this.transformer = transformer
transformer.start(edited, output)
} catch (e: Exception) {
Log.w(LOG_TAG, "export start failed: ${e.message}")
finish(false)
}
}
private fun finish(ok: Boolean) {
val result = pending
pending = null
release()
result?.success(ok)
}
private fun buildClipping(call: MethodCall): MediaItem.ClippingConfiguration {
val builder = MediaItem.ClippingConfiguration.Builder()
val start = call.argument<Number>("startMs")?.toLong()
val end = call.argument<Number>("endMs")?.toLong()
if (start != null && start > 0) builder.setStartPositionMs(start)
if (end != null && end > 0) builder.setEndPositionMs(end)
return builder.build()
}
private fun buildEffects(call: MethodCall): List<Effect> {
val effects = mutableListOf<Effect>()
val rotation = call.argument<Number>("rotationDegrees")?.toFloat() ?: 0f
val flipH = call.argument<Boolean>("flipH") == true
if (flipH || kotlin.math.abs(rotation) > 0.01f) {
effects.add(
ScaleAndRotateTransformation.Builder()
.setScale(if (flipH) -1f else 1f, 1f)
.setRotationDegrees(rotation)
.build(),
)
}
val crop = call.argument<List<Double>>("crop")
if (crop != null && crop.size == 4) {
effects.add(
Crop(
crop[0].toFloat(),
crop[1].toFloat(),
crop[2].toFloat(),
crop[3].toFloat(),
),
)
}
val width = call.argument<Int>("outWidth") ?: 0
val height = call.argument<Int>("outHeight") ?: 0
if (width > 0 && height > 0) {
effects.add(
Presentation.createForWidthAndHeight(
width,
height,
Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP,
),
)
}
val matrix = call.argument<List<Double>>("rgbMatrix")
if (matrix != null && matrix.size == 16) {
effects.add(
ColorMatrixEffect(FloatArray(16) { matrix[it].toFloat() }),
)
}
val overlay = call.argument<String>("overlay")
if (overlay != null) {
val bitmap = BitmapFactory.decodeFile(overlay)
if (bitmap != null) {
overlayBitmap = bitmap
effects.add(
OverlayEffect(
listOf<TextureOverlay>(
BitmapOverlay.createStaticBitmapOverlay(bitmap),
),
),
)
}
}
return effects
}
fun progress(result: MethodChannel.Result) {
val active = transformer
if (active == null) {
result.success(-1)
return
}
val state = try {
active.getProgress(progressHolder)
} catch (_: IllegalStateException) {
result.success(-1)
return
}
result.success(
if (state == Transformer.PROGRESS_STATE_AVAILABLE) {
progressHolder.progress
} else {
-1
},
)
}
fun cancel(result: MethodChannel.Result) {
try {
transformer?.cancel()
} catch (e: Exception) {
Log.w(LOG_TAG, "cancel failed: ${e.message}")
}
finish(false)
result.success(null)
}
private fun release() {
transformer = null
overlayBitmap?.recycle()
overlayBitmap = null
}
}
File diff suppressed because one or more lines are too long
+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": "Качество"
}
+114
View File
@@ -0,0 +1,114 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/attachment/editor_common.dart';
import 'package:komet/l10n/app_localizations.dart';
Future<ui.Image> _solidImage(int width, int height) {
final recorder = ui.PictureRecorder();
Canvas(recorder).drawRect(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
Paint()..color = const Color(0xFF3366AA),
);
return recorder.endRecording().toImage(width, height);
}
void main() {
group('CropView', () {
const vp = Size(400, 800);
test('рамка во весь вьюпорт даёт единичный масштаб', () {
final crop = Rect.fromCenter(
center: const Offset(200, 400),
width: 360,
height: 720,
);
final view = CropView.fit(crop, vp);
expect(view.scale, closeTo(1, 0.001));
expect(view.focus, crop.center);
});
test('маленькая рамка приближает и центрирует', () {
final crop = Rect.fromLTWH(40, 80, 90, 180);
final view = CropView.fit(crop, vp);
expect(view.scale, closeTo(4, 0.001));
final display = view.rect(crop, vp);
expect(display.center.dx, closeTo(200, 0.001));
expect(display.center.dy, closeTo(400, 0.001));
expect(display.width, closeTo(360, 0.001));
});
test('перевод координат обратим', () {
final view = CropView.fit(Rect.fromLTWH(40, 80, 90, 180), vp);
const point = Offset(123, 456);
final round = view.toLogical(view.toDisplay(point, vp), vp);
expect(round.dx, closeTo(point.dx, 0.001));
expect(round.dy, closeTo(point.dy, 0.001));
});
});
testWidgets('после зума жест по рамке двигает её в масштабе', (tester) async {
final image = await _solidImage(400, 400);
addTearDown(image.dispose);
CropState? applied;
Size? viewport;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: CropWorkspace(
imageSize: const Size(400, 400),
imageBuilder: (context, matrix) =>
CustomPaint(painter: MatrixImagePainter(image, matrix)),
onApply: (state, vp, changed, identity) async {
applied = state;
viewport = vp;
return 'ok';
},
),
),
);
await tester.pumpAndSettle();
final area = tester.getRect(find.byType(LayoutBuilder).first);
final geometry = const CropGeometry(source: Size(400, 400));
final fitted = geometry.fittedRect(area.size);
final corner = area.topLeft + fitted.topLeft;
Future<void> dragCorner(Offset from, Offset delta) async {
final gesture = await tester.startGesture(from);
await tester.pump();
await gesture.moveBy(delta);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
}
await dragCorner(corner, const Offset(40, 40));
final zoomed = Rect.fromLTRB(
fitted.left + 40,
fitted.top + 40,
fitted.right,
fitted.bottom,
);
final view = CropView.fit(zoomed, area.size);
expect(view.scale, greaterThan(1));
await dragCorner(
area.topLeft + view.rect(zoomed, area.size).topLeft,
const Offset(40, 40),
);
await tester.tap(find.text('ГОТОВО'));
await tester.pumpAndSettle();
expect(applied, isNotNull);
final left = applied!.cropNorm.left * viewport!.width;
expect(left, closeTo(fitted.left + 40 + 40 / view.scale, 1.5));
expect(left, lessThan(fitted.left + 80));
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/l10n/app_localizations.dart';
void main() {
late Directory tmp;
late File source;
setUpAll(() async {
tmp = Directory.systemTemp.createTempSync('komet_markup_test');
final recorder = ui.PictureRecorder();
Canvas(recorder).drawRect(
const Rect.fromLTWH(0, 0, 8, 8),
Paint()..color = const Color(0xFF224466),
);
final image = await recorder.endRecording().toImage(8, 8);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
source = File('${tmp.path}/source.png')
..writeAsBytesSync(data!.buffer.asUint8List());
});
tearDownAll(() => tmp.deleteSync(recursive: true));
testWidgets('пустая разметка закрывается без результата', (tester) async {
var previews = 0;
Object? popped = 'untouched';
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (ctx) {
context = ctx;
return const Scaffold();
},
),
),
);
unawaited(
Navigator.of(context)
.push<Object?>(
MaterialPageRoute<Object?>(
builder: (_) => PhotoDrawEditor(
source: source,
imageWidth: 8,
imageHeight: 8,
onPreview: (_) async => previews++,
),
),
)
.then((value) => popped = value),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Symbols.check));
await tester.pumpAndSettle();
expect(previews, 0);
expect(popped, isNull);
});
}
+39
View File
@@ -65,9 +65,11 @@ double _pageOpacity(WidgetTester tester) => tester
void main() {
late ui.Image image;
late ui.Image wide;
setUpAll(() async {
image = await createTestImage(width: 4, height: 3);
wide = await createTestImage(width: 8, height: 2);
});
testWidgets('photo flies from the origin rect to the contained target', (
@@ -185,4 +187,41 @@ void main() {
expect(find.byType(Opacity), findsNothing);
expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500);
});
testWidgets('замена кадра меняет пропорции обратного перелёта', (
tester,
) async {
final hero = PhotoHeroController(
origin: () => _origin,
image: RawImageProvider(image),
);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (ctx) {
context = ctx;
return const Scaffold();
},
),
),
);
final navigator = Navigator.of(context);
navigator.push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
await tester.pumpAndSettle();
hero.image.value = RawImageProvider(wide);
await tester.pump();
navigator.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(_flying, findsOneWidget);
final size = tester.getSize(_flying);
expect(size.width / size.height, closeTo(4, 0.2));
await tester.pump(const Duration(milliseconds: 400));
});
}
+318
View File
@@ -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)));
});
}
+8
View File
@@ -659,6 +659,8 @@ def pair_glyphs(from_cp, to_cp, count, fill=0.0):
MIC = 0xE31D
CAM = 0xE04B
SEND = 0xE163
VOLUME_UP = 0xE050
VOLUME_OFF = 0xE04F
FLASH_ON = 0xE3E7
FLASH_OFF = 0xE3E6
@@ -985,6 +987,12 @@ SLASH_SPECS = [
fill=1.0,
scale=[(0, 100), (11, 92), (DUR, 100)],
),
dict(
name='ic_volume_on_to_off',
plain_cp=VOLUME_UP, slashed_cp=VOLUME_OFF,
fill=1.0,
scale=[(0, 100), (11, 92), (DUR, 100)],
),
]