feat: редактирование видосов + hero анимация при переходах и зум при кадрировании
This commit is contained in:
@@ -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
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -906,5 +906,13 @@
|
||||
}
|
||||
},
|
||||
"blacklistEmpty": "Никто не заблокирован",
|
||||
"blacklistLoadError": "Не удалось загрузить чёрный список"
|
||||
"blacklistLoadError": "Не удалось загрузить чёрный список",
|
||||
"videoEditorQualityLow": "Небольшой размер",
|
||||
"videoEditorQualityHigh": "Высокое качество",
|
||||
"videoEditorCaptionHint": "Добавить подпись...",
|
||||
"videoEditorMuteTooltip": "Отправить без звука",
|
||||
"videoEditorProcessing": "Обработка видео…",
|
||||
"videoEditorExportFailed": "Не удалось обработать видео",
|
||||
"videoEditorFrameFailed": "Не удалось получить кадр",
|
||||
"videoEditorQualityTooltip": "Качество"
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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)],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user