diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 98fd9c2..5650c7c 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -291,6 +291,32 @@ class MainActivity : FlutterActivity() { cropSquare(input, output, size, result) } } + "probe" -> { + val input = call.argument("input") + if (input == null) { + result.error("BAD_ARGS", "input required", null) + } else { + probeVideo(input, result) + } + } + "frames" -> { + val input = call.argument("input") + val times = call.argument>("times") + if (input == null || times == null) { + result.error("BAD_ARGS", "input/times required", null) + } else { + videoFrames( + input, + times, + call.argument("size") ?: 256, + call.argument("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, + 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 { val adapter = nfcAdapter return mapOf( diff --git a/android/app/src/main/kotlin/ru/komet/app/VideoEditor.kt b/android/app/src/main/kotlin/ru/komet/app/VideoEditor.kt new file mode 100644 index 0000000..7f55c32 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/VideoEditor.kt @@ -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? { + 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 { + 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, + size: Int, + precise: Boolean, + result: MethodChannel.Result, + ) { + Thread { + val out = ArrayList(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("input") + val output = call.argument("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("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("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("startMs")?.toLong() + val end = call.argument("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 { + val effects = mutableListOf() + val rotation = call.argument("rotationDegrees")?.toFloat() ?: 0f + val flipH = call.argument("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>("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("outWidth") ?: 0 + val height = call.argument("outHeight") ?: 0 + if (width > 0 && height > 0) { + effects.add( + Presentation.createForWidthAndHeight( + width, + height, + Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, + ), + ) + } + val matrix = call.argument>("rgbMatrix") + if (matrix != null && matrix.size == 16) { + effects.add( + ColorMatrixEffect(FloatArray(16) { matrix[it].toFloat() }), + ) + } + val overlay = call.argument("overlay") + if (overlay != null) { + val bitmap = BitmapFactory.decodeFile(overlay) + if (bitmap != null) { + overlayBitmap = bitmap + effects.add( + OverlayEffect( + listOf( + 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 + } +} diff --git a/assets/lottie/ic_volume_on_to_off.json b/assets/lottie/ic_volume_on_to_off.json new file mode 100644 index 0000000..ed5f670 --- /dev/null +++ b/assets/lottie/ic_volume_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_volume_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[13.88,13.88],[8.11,8.11],[0.0,0.0],[11.67,-4.79],[12.5,-2.92],[0.0,12.81],[0.0,0.0],[-5.62,2.08],[-5.0,2.92],[15.26,15.26],[8.92,8.92],[0.0,0.0],[0.0,-18.65],[0.0,-11.29],[0.0,-9.93],[0.0,0.0],[14.69,14.69],[8.95,8.95],[8.23,8.23],[7.46,7.46],[0.0,0.0],[18.36,0.0],[10.73,0.0],[0.0,0.0],[0.0,21.52],[0.0,13.03],[0.0,11.45],[0.0,0.0],[-20.0,0.0],[0.0,0.0],[14.1,14.1],[8.59,8.59],[7.91,7.91],[7.16,7.16],[0.0,0.0],[0.0,0.0],[-15.3,-15.3],[-9.21,-9.21],[-8.69,-8.69],[-8.46,-8.46],[-8.33,-8.33],[-8.27,-8.27],[-8.19,-8.19],[-8.15,-8.15],[-8.11,-8.11],[-8.06,-8.06],[-8.03,-8.03],[-7.98,-7.98],[-7.93,-7.93],[-7.87,-7.87],[-7.78,-7.78],[-7.65,-7.65],[-7.42,-7.42],[-6.86,-6.86],[0.0,0.0]],"o":[[0.0,0.0],[-8.11,-8.11],[-13.88,-13.88],[-10.42,6.67],[-11.67,4.79],[0.0,0.0],[0.0,-12.81],[5.83,-2.08],[5.62,-2.08],[0.0,0.0],[-8.92,-8.92],[-15.26,-15.26],[0.0,0.0],[0.0,9.93],[0.0,11.29],[0.0,18.65],[0.0,0.0],[-7.46,-7.46],[-8.23,-8.23],[-8.95,-8.95],[-14.69,-14.69],[0.0,0.0],[-10.73,0.0],[-18.36,0.0],[0.0,0.0],[0.0,-11.45],[0.0,-13.03],[0.0,-21.52],[0.0,0.0],[20.0,0.0],[0.0,0.0],[-7.16,-7.16],[-7.91,-7.91],[-8.59,-8.59],[-14.1,-14.1],[0.0,0.0],[0.0,0.0],[6.86,6.86],[7.42,7.42],[7.65,7.65],[7.78,7.78],[7.87,7.87],[7.93,7.93],[7.98,7.98],[8.03,8.03],[8.06,8.06],[8.11,8.11],[8.15,8.15],[8.19,8.19],[8.27,8.27],[8.33,8.33],[8.46,8.46],[8.69,8.69],[9.21,9.21],[15.3,15.3],[0.0,0.0]],"v":[[495.0,565.0],[469.79,539.79],[444.58,514.58],[419.38,489.38],[386.25,506.56],[350.0,518.12],[350.0,492.5],[350.0,466.88],[367.19,460.62],[383.12,453.12],[355.42,425.42],[327.71,397.71],[300.0,370.0],[300.0,402.5],[300.0,435.0],[300.0,467.5],[300.0,500.0],[275.01,475.01],[250.0,450.0],[225.0,425.0],[199.99,399.99],[175.0,375.0],[141.67,375.0],[108.33,375.0],[75.0,375.0],[75.0,337.5],[75.0,300.0],[75.0,262.5],[75.0,225.0],[115.0,225.0],[155.0,225.0],[131.01,201.01],[107.0,177.0],[83.0,153.0],[58.99,128.99],[35.0,105.0],[70.0,70.0],[94.19,94.19],[118.4,118.4],[142.62,142.62],[166.83,166.83],[191.03,191.03],[215.26,215.26],[239.46,239.46],[263.68,263.68],[287.89,287.89],[312.11,312.11],[336.32,336.32],[360.54,360.54],[384.74,384.74],[408.97,408.97],[433.17,433.17],[457.38,457.38],[481.6,481.6],[505.81,505.81],[530.0,530.0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.83,-4.4],[5.2,5.2],[3.15,3.15],[2.77,2.77],[0.0,0.0],[-1.96,4.43],[-1.58,4.59],[-1.18,4.73],[-0.79,4.85],[-0.39,4.92],[0.0,4.95],[0.44,5.27],[0.91,5.23],[1.4,5.14],[1.9,5.0],[2.38,4.82],[2.83,4.6],[3.23,4.35],[3.51,3.94],[3.88,3.61],[4.24,3.23],[4.55,2.81],[4.82,2.37],[5.04,1.93],[5.2,1.5],[0.0,7.35],[0.0,4.45],[0.0,3.91],[0.0,0.0],[-4.8,-1.39],[-4.71,-1.7],[-4.6,-2.02],[-4.46,-2.34],[-4.29,-2.65],[-4.1,-2.96],[-3.89,-3.25],[-3.66,-3.52],[-3.43,-3.77],[-3.19,-3.98],[-2.87,-4.1],[-2.6,-4.29],[-2.3,-4.47],[-1.98,-4.63],[-1.65,-4.77],[-1.31,-4.89],[-0.97,-4.98],[-0.63,-5.05],[-0.31,-5.09],[0.0,-5.11],[0.37,-5.33],[0.75,-5.3],[1.14,-5.25],[1.53,-5.16],[1.81,-4.87],[2.16,-4.74],[2.5,-4.58]],"o":[[0.0,0.0],[-2.77,-2.77],[-3.15,-3.15],[-5.2,-5.2],[2.35,-4.29],[1.97,-4.45],[1.58,-4.59],[1.19,-4.76],[0.79,-4.83],[0.39,-4.86],[0.0,-5.43],[-0.45,-5.39],[-0.93,-5.31],[-1.41,-5.18],[-1.9,-5.0],[-2.36,-4.77],[-2.78,-4.51],[-3.22,-4.34],[-3.58,-4.02],[-3.93,-3.65],[-4.24,-3.24],[-4.52,-2.79],[-4.75,-2.34],[-4.92,-1.89],[0.0,0.0],[0.0,-3.91],[0.0,-4.45],[0.0,-7.35],[4.98,1.13],[4.9,1.42],[4.79,1.73],[4.65,2.04],[4.49,2.35],[4.3,2.66],[4.09,2.95],[3.86,3.23],[3.62,3.48],[3.37,3.71],[3.19,3.98],[2.92,4.17],[2.63,4.34],[2.32,4.51],[1.99,4.65],[1.65,4.76],[1.3,4.86],[0.96,4.93],[0.62,4.97],[0.3,4.99],[0.0,5.43],[-0.37,5.41],[-0.75,5.35],[-1.14,5.27],[-1.49,5.05],[-1.83,4.92],[-2.17,4.77],[-2.5,4.59]],"v":[[490.0,420.0],[480.94,410.94],[471.88,401.88],[462.81,392.81],[453.75,383.75],[460.22,370.68],[465.55,357.11],[469.69,343.12],[472.66,328.7],[474.42,314.09],[475.0,299.37],[474.34,283.33],[472.29,267.4],[468.8,251.72],[463.83,236.45],[457.41,221.72],[449.64,207.67],[440.62,194.38],[430.52,181.95],[419.32,170.51],[407.07,160.19],[393.88,151.12],[379.86,143.37],[365.19,136.96],[350.0,131.88],[350.0,119.06],[350.0,106.25],[350.0,93.44],[350.0,80.63],[364.68,84.4],[379.1,89.09],[393.18,94.71],[406.84,101.28],[420.0,108.79],[432.6,117.23],[444.56,126.54],[455.84,136.66],[466.41,147.53],[476.25,159.06],[485.34,171.19],[493.62,183.87],[501.01,197.09],[507.47,210.8],[512.93,224.93],[517.36,239.42],[520.76,254.18],[523.15,269.14],[524.55,284.23],[525.0,299.37],[524.45,315.52],[522.78,331.58],[519.94,347.48],[515.94,363.12],[510.98,378.0],[505.0,392.49],[498.0,406.52]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.69,-1.94],[2.51,2.51],[1.52,1.52],[1.43,1.43],[1.39,1.39],[1.37,1.37],[1.36,1.36],[1.34,1.34],[1.33,1.33],[1.32,1.32],[1.3,1.3],[1.28,1.28],[1.24,1.24],[1.15,1.15],[0.0,0.0],[0.0,3.63],[0.0,2.2],[0.0,2.07],[0.0,2.01],[0.0,1.98],[0.0,1.96],[0.0,1.94],[0.0,1.92],[0.0,1.9],[0.0,1.88],[0.0,1.85],[0.0,1.79],[0.0,1.65],[0.0,0.0],[-1.67,-0.88],[-1.62,-0.97],[-1.57,-1.06],[-1.51,-1.14],[-1.45,-1.23],[-1.38,-1.31],[-1.3,-1.38],[-1.23,-1.46],[-1.15,-1.52],[-1.07,-1.58],[-0.99,-1.64],[-0.9,-1.67],[-0.82,-1.72],[-0.73,-1.76],[-0.65,-1.79],[-0.55,-1.83],[-0.46,-1.86],[-0.37,-1.88],[-0.27,-1.9],[-0.18,-1.91],[-0.09,-1.92],[0.0,-1.92],[0.11,-2.05],[0.23,-2.04],[0.35,-2.03],[0.46,-2.0],[0.58,-1.97]],"o":[[0.0,0.0],[-1.15,-1.15],[-1.24,-1.24],[-1.28,-1.28],[-1.3,-1.3],[-1.32,-1.32],[-1.33,-1.33],[-1.34,-1.34],[-1.36,-1.36],[-1.37,-1.37],[-1.39,-1.39],[-1.43,-1.43],[-1.52,-1.52],[-2.51,-2.51],[0.0,0.0],[0.0,-1.65],[0.0,-1.79],[0.0,-1.85],[0.0,-1.88],[0.0,-1.9],[0.0,-1.92],[0.0,-1.94],[0.0,-1.96],[0.0,-1.98],[0.0,-2.01],[0.0,-2.07],[0.0,-2.2],[0.0,-3.63],[1.73,0.81],[1.69,0.9],[1.64,0.98],[1.58,1.06],[1.52,1.15],[1.45,1.23],[1.38,1.31],[1.3,1.38],[1.22,1.45],[1.14,1.51],[1.05,1.57],[0.99,1.65],[0.91,1.69],[0.83,1.73],[0.74,1.77],[0.65,1.8],[0.55,1.83],[0.46,1.85],[0.36,1.87],[0.27,1.88],[0.18,1.89],[0.09,1.9],[0.0,2.07],[-0.12,2.06],[-0.23,2.05],[-0.35,2.02],[-0.46,1.99],[-0.58,1.96]],"v":[[406.25,336.25],[402.24,332.24],[398.22,328.22],[394.2,324.2],[390.18,320.18],[386.16,316.16],[382.14,312.14],[378.12,308.12],[374.11,304.11],[370.09,300.09],[366.07,296.07],[362.05,292.05],[358.03,288.03],[354.01,284.01],[350.0,280.0],[350.0,274.21],[350.0,268.4],[350.0,262.59],[350.0,256.79],[350.0,250.98],[350.0,245.18],[350.0,239.38],[350.0,233.57],[350.0,227.77],[350.0,221.96],[350.0,216.16],[350.0,210.35],[350.0,204.54],[350.0,198.75],[355.1,201.29],[360.06,204.09],[364.87,207.14],[369.51,210.45],[373.96,214.01],[378.2,217.81],[382.23,221.85],[386.02,226.1],[389.57,230.56],[392.88,235.2],[395.94,240.0],[398.78,244.98],[401.37,250.08],[403.72,255.31],[405.79,260.66],[407.59,266.09],[409.11,271.62],[410.35,277.21],[411.3,282.86],[411.97,288.56],[412.37,294.27],[412.5,300.0],[412.33,306.17],[411.81,312.32],[410.94,318.44],[409.72,324.46],[408.15,330.41]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[2.55,2.55],[1.54,1.54],[1.45,1.45],[1.42,1.42],[1.39,1.39],[1.38,1.38],[1.37,1.37],[1.36,1.36],[1.35,1.35],[1.34,1.34],[1.33,1.33],[1.31,1.31],[1.29,1.29],[1.25,1.25],[1.15,1.15],[0.0,0.0],[-2.41,2.41],[-1.45,1.45],[-1.37,1.37],[-1.33,1.33],[-1.31,1.31],[-1.3,1.3],[-1.29,1.29],[-1.28,1.28],[-1.27,1.27],[-1.27,1.27],[-1.26,1.26],[-1.25,1.25],[-1.23,1.23],[-1.21,1.21],[-1.17,1.17],[-1.09,1.09],[0.0,0.0],[0.0,-3.59],[0.0,-2.15],[0.0,-2.03],[0.0,-1.99],[0.0,-1.95],[0.0,-1.94],[0.0,-1.92],[0.0,-1.91],[0.0,-1.9],[0.0,-1.9],[0.0,-1.89],[0.0,-1.88],[0.0,-1.88],[0.0,-1.87],[0.0,-1.86],[0.0,-1.85],[0.0,-1.84],[0.0,-1.83],[0.0,-1.81],[0.0,-1.78],[0.0,-1.73],[0.0,-1.59]],"o":[[0.0,0.0],[-1.15,-1.15],[-1.25,-1.25],[-1.29,-1.29],[-1.31,-1.31],[-1.33,-1.33],[-1.34,-1.34],[-1.35,-1.35],[-1.36,-1.36],[-1.37,-1.37],[-1.38,-1.38],[-1.39,-1.39],[-1.42,-1.42],[-1.45,-1.45],[-1.54,-1.54],[-2.55,-2.55],[0.0,0.0],[1.09,-1.09],[1.17,-1.17],[1.21,-1.21],[1.23,-1.23],[1.25,-1.25],[1.26,-1.26],[1.27,-1.27],[1.27,-1.27],[1.28,-1.28],[1.29,-1.29],[1.3,-1.3],[1.31,-1.31],[1.33,-1.33],[1.37,-1.37],[1.45,-1.45],[2.41,-2.41],[0.0,0.0],[0.0,1.59],[0.0,1.73],[0.0,1.78],[0.0,1.81],[0.0,1.83],[0.0,1.84],[0.0,1.85],[0.0,1.86],[0.0,1.87],[0.0,1.88],[0.0,1.88],[0.0,1.89],[0.0,1.9],[0.0,1.9],[0.0,1.91],[0.0,1.92],[0.0,1.94],[0.0,1.95],[0.0,1.99],[0.0,2.03],[0.0,2.15],[0.0,3.59]],"v":[[300.0,230.0],[295.94,225.94],[291.88,221.88],[287.82,217.82],[283.75,213.75],[279.69,209.69],[275.63,205.63],[271.56,201.56],[267.5,197.5],[263.44,193.44],[259.37,189.37],[255.31,185.31],[251.25,181.25],[247.18,177.18],[243.12,173.12],[239.06,169.06],[235.0,165.0],[238.82,161.18],[242.64,157.36],[246.47,153.53],[250.29,149.71],[254.12,145.88],[257.94,142.06],[261.76,138.24],[265.59,134.41],[269.41,130.59],[273.24,126.76],[277.06,122.94],[280.88,119.12],[284.71,115.29],[288.53,111.47],[292.36,107.64],[296.18,103.82],[300.0,100.0],[300.0,105.65],[300.0,111.29],[300.0,116.94],[300.0,122.61],[300.0,128.25],[300.0,133.91],[300.0,139.56],[300.0,145.21],[300.0,150.87],[300.0,156.52],[300.0,162.17],[300.0,167.83],[300.0,173.48],[300.0,179.13],[300.0,184.79],[300.0,190.44],[300.0,196.09],[300.0,201.75],[300.0,207.39],[300.0,213.06],[300.0,218.71],[300.0,224.35]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,4.47],[0.0,2.72],[0.0,2.56],[0.0,2.47],[0.0,2.4],[0.0,2.31],[0.0,2.13],[0.0,0.0],[3.06,3.06],[1.86,1.86],[1.75,1.75],[1.7,1.7],[1.66,1.66],[1.62,1.62],[1.57,1.57],[1.44,1.44],[0.0,0.0],[0.0,0.0],[4.93,0.0],[2.99,0.0],[2.82,0.0],[2.74,0.0],[2.68,0.0],[2.64,0.0],[2.58,0.0],[2.49,0.0],[2.3,0.0],[0.0,0.0],[0.0,-4.31],[0.0,-2.63],[0.0,-2.46],[0.0,-2.37],[0.0,-2.27],[0.0,-2.09],[0.0,0.0],[-4.39,0.0],[-2.67,0.0],[-2.51,0.0],[-2.44,0.0],[-2.39,0.0],[-2.35,0.0],[-2.3,0.0],[-2.22,0.0],[-2.05,0.0],[0.0,0.0],[-3.03,-3.03],[-1.83,-1.83],[-1.73,-1.73],[-1.68,-1.68],[-1.65,-1.65],[-1.62,-1.62],[-1.6,-1.6],[-1.57,-1.57],[-1.52,-1.52],[-1.4,-1.4]],"o":[[0.0,0.0],[0.0,-2.13],[0.0,-2.31],[0.0,-2.4],[0.0,-2.47],[0.0,-2.56],[0.0,-2.72],[0.0,-4.47],[0.0,0.0],[-1.44,-1.44],[-1.57,-1.57],[-1.62,-1.62],[-1.66,-1.66],[-1.7,-1.7],[-1.75,-1.75],[-1.86,-1.86],[-3.06,-3.06],[0.0,0.0],[0.0,0.0],[-2.3,0.0],[-2.49,0.0],[-2.58,0.0],[-2.64,0.0],[-2.68,0.0],[-2.74,0.0],[-2.82,0.0],[-2.99,0.0],[-4.93,0.0],[0.0,0.0],[0.0,2.09],[0.0,2.27],[0.0,2.37],[0.0,2.46],[0.0,2.63],[0.0,4.31],[0.0,0.0],[2.05,0.0],[2.22,0.0],[2.3,0.0],[2.35,0.0],[2.39,0.0],[2.44,0.0],[2.51,0.0],[2.67,0.0],[4.39,0.0],[0.0,0.0],[1.4,1.4],[1.52,1.52],[1.57,1.57],[1.6,1.6],[1.62,1.62],[1.65,1.65],[1.68,1.68],[1.73,1.73],[1.83,1.83],[3.03,3.03]],"v":[[250.0,378.75],[250.0,371.41],[250.0,364.06],[250.0,356.72],[250.0,349.37],[250.0,342.03],[250.0,334.69],[250.0,327.34],[250.0,320.0],[245.0,315.0],[240.0,310.0],[235.0,305.0],[230.0,300.0],[225.0,295.0],[220.0,290.0],[215.0,285.0],[210.0,280.0],[205.0,275.0],[205.0,275.0],[197.01,275.0],[189.01,275.0],[181.0,275.0],[173.0,275.0],[165.0,275.0],[157.0,275.0],[149.0,275.0],[140.99,275.0],[132.99,275.0],[125.0,275.0],[125.0,282.14],[125.0,289.28],[125.0,296.43],[125.0,303.57],[125.0,310.72],[125.0,317.86],[125.0,325.0],[132.12,325.0],[139.24,325.0],[146.37,325.0],[153.5,325.0],[160.62,325.0],[167.75,325.0],[174.88,325.0],[182.01,325.0],[189.13,325.0],[196.25,325.0],[201.13,329.88],[206.02,334.77],[210.91,339.66],[215.79,344.54],[220.68,349.43],[225.57,354.32],[230.46,359.21],[235.34,364.09],[240.23,368.98],[245.12,373.87]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0.0,0.0],[3.03,3.03],[1.83,1.83],[1.73,1.73],[1.68,1.68],[1.65,1.65],[1.62,1.62],[1.6,1.6],[1.57,1.57],[1.52,1.52],[1.4,1.4],[0.0,0.0],[4.39,0.0],[2.67,0.0],[2.51,0.0],[2.44,0.0],[2.39,0.0],[2.35,0.0],[2.3,0.0],[2.22,0.0],[2.05,0.0],[0.0,0.0],[0.0,4.31],[0.0,2.63],[0.0,2.46],[0.0,2.37],[0.0,2.27],[0.0,2.09],[0.0,0.0],[-4.93,0.0],[-2.99,0.0],[-2.82,0.0],[-2.74,0.0],[-2.68,0.0],[-2.64,0.0],[-2.58,0.0],[-2.49,0.0],[-2.3,0.0],[0.0,0.0],[0.0,0.0],[-3.06,-3.06],[-1.86,-1.86],[-1.75,-1.75],[-1.7,-1.7],[-1.66,-1.66],[-1.62,-1.62],[-1.57,-1.57],[-1.44,-1.44],[0.0,0.0],[0.0,-4.47],[0.0,-2.72],[0.0,-2.56],[0.0,-2.47],[0.0,-2.4],[0.0,-2.31],[0.0,-2.13]],"o":[[0.0,0.0],[-1.4,-1.4],[-1.52,-1.52],[-1.57,-1.57],[-1.6,-1.6],[-1.62,-1.62],[-1.65,-1.65],[-1.68,-1.68],[-1.73,-1.73],[-1.83,-1.83],[-3.03,-3.03],[0.0,0.0],[-2.05,0.0],[-2.22,0.0],[-2.3,0.0],[-2.35,0.0],[-2.39,0.0],[-2.44,0.0],[-2.51,0.0],[-2.67,0.0],[-4.39,0.0],[0.0,0.0],[0.0,-2.09],[0.0,-2.27],[0.0,-2.37],[0.0,-2.46],[0.0,-2.63],[0.0,-4.31],[0.0,0.0],[2.3,0.0],[2.49,0.0],[2.58,0.0],[2.64,0.0],[2.68,0.0],[2.74,0.0],[2.82,0.0],[2.99,0.0],[4.93,0.0],[0.0,0.0],[0.0,0.0],[1.44,1.44],[1.57,1.57],[1.62,1.62],[1.66,1.66],[1.7,1.7],[1.75,1.75],[1.86,1.86],[3.06,3.06],[0.0,0.0],[0.0,2.13],[0.0,2.31],[0.0,2.4],[0.0,2.47],[0.0,2.56],[0.0,2.72],[0.0,4.47]],"v":[[250.0,378.75],[245.12,373.87],[240.23,368.98],[235.34,364.09],[230.46,359.21],[225.57,354.32],[220.68,349.43],[215.79,344.54],[210.91,339.66],[206.02,334.77],[201.13,329.88],[196.25,325.0],[189.13,325.0],[182.01,325.0],[174.88,325.0],[167.75,325.0],[160.63,325.0],[153.5,325.0],[146.37,325.0],[139.24,325.0],[132.12,325.0],[125.0,325.0],[125.0,317.86],[125.0,310.72],[125.0,303.57],[125.0,296.43],[125.0,289.28],[125.0,282.14],[125.0,275.0],[132.99,275.0],[140.99,275.0],[149.0,275.0],[157.0,275.0],[165.0,275.0],[173.0,275.0],[181.0,275.0],[189.01,275.0],[197.01,275.0],[205.0,275.0],[205.0,275.0],[210.0,280.0],[215.0,285.0],[220.0,290.0],[225.0,295.0],[230.0,300.0],[235.0,305.0],[240.0,310.0],[245.0,315.0],[250.0,320.0],[250.0,327.34],[250.0,334.69],[250.0,342.03],[250.0,349.38],[250.0,356.72],[250.0,364.06],[250.0,371.41]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"slashed","np":8,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[7.15,-1.61],[0.0,9.41],[0.0,5.5],[0.0,0.0],[-6.76,2.88],[-6.37,3.77],[-5.85,4.65],[-5.22,5.44],[-4.53,6.1],[-3.15,5.31],[-2.57,5.65],[-1.93,5.94],[-1.27,6.16],[-0.62,6.29],[0.0,6.35],[0.6,6.13],[1.25,6.06],[1.93,5.92],[2.59,5.7],[3.22,5.42],[3.78,5.08],[5.1,5.32],[5.82,4.62],[6.46,3.82],[6.96,2.96],[7.32,2.11],[0.0,9.41],[0.0,5.5],[0.0,0.0],[-6.78,-2.17],[-6.57,-2.81],[-6.29,-3.46],[-5.93,-4.11],[-5.52,-4.71],[-5.05,-5.25],[-4.57,-5.71],[-3.93,-5.95],[-3.34,-6.33],[-2.7,-6.67],[-2.01,-6.95],[-1.32,-7.15],[-0.64,-7.27],[0.0,-7.32],[0.62,-7.09],[1.29,-7.02],[2.0,-6.89],[2.7,-6.69],[3.38,-6.41],[4.01,-6.08],[4.57,-5.71],[4.95,-5.14],[5.45,-4.65],[5.92,-4.1],[6.34,-3.49],[6.69,-2.86],[6.96,-2.22]],"o":[[0.0,0.0],[0.0,-5.5],[0.0,-9.41],[7.32,-2.11],[6.96,-2.96],[6.46,-3.82],[5.82,-4.62],[5.1,-5.32],[3.78,-5.08],[3.22,-5.42],[2.59,-5.7],[1.93,-5.92],[1.25,-6.06],[0.6,-6.13],[0.0,-6.35],[-0.62,-6.29],[-1.27,-6.16],[-1.93,-5.94],[-2.57,-5.65],[-3.15,-5.31],[-4.53,-6.1],[-5.22,-5.44],[-5.85,-4.65],[-6.37,-3.77],[-6.76,-2.88],[0.0,0.0],[0.0,-5.5],[0.0,-9.41],[7.15,1.61],[6.96,2.22],[6.69,2.86],[6.34,3.49],[5.92,4.1],[5.45,4.65],[4.95,5.14],[4.57,5.71],[4.01,6.08],[3.38,6.41],[2.7,6.69],[2.0,6.89],[1.29,7.02],[0.62,7.09],[0.0,7.32],[-0.64,7.27],[-1.32,7.15],[-2.01,6.95],[-2.7,6.67],[-3.34,6.33],[-3.93,5.95],[-4.57,5.71],[-5.05,5.25],[-5.52,4.71],[-5.93,4.11],[-6.29,3.46],[-6.57,2.81],[-6.78,2.17]],"v":[[350.0,518.12],[350.0,501.04],[350.0,483.96],[350.0,466.88],[371.13,459.39],[391.14,449.29],[409.6,436.59],[426.17,421.5],[440.62,404.38],[451.02,388.79],[459.71,372.19],[466.5,354.73],[471.29,336.62],[474.1,318.09],[475.0,299.37],[474.1,280.66],[471.29,262.13],[466.5,244.02],[459.71,226.56],[451.02,209.96],[440.62,194.38],[426.17,177.25],[409.6,162.16],[391.14,149.46],[371.13,139.36],[350.0,131.88],[350.0,114.79],[350.0,97.71],[350.0,80.63],[370.9,86.3],[391.19,93.85],[410.65,103.33],[429.06,114.72],[446.22,127.93],[461.97,142.78],[476.25,159.06],[488.99,176.55],[500.01,195.17],[509.13,214.8],[516.2,235.25],[521.17,256.31],[524.07,277.75],[525.0,299.37],[524.07,321.0],[521.17,342.44],[516.2,363.5],[509.13,383.95],[500.01,403.58],[488.99,422.2],[476.25,439.69],[461.97,455.97],[446.22,470.82],[429.06,484.03],[410.65,495.42],[391.19,504.9],[370.9,512.45]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,11.42],[0.0,6.95],[0.0,6.52],[0.0,6.31],[0.0,6.13],[0.0,5.9],[0.0,5.44],[0.0,0.0],[-11.75,0.0],[-7.16,0.0],[-6.59,0.0],[-5.97,0.0],[0.0,0.0],[-8.51,8.51],[-5.17,5.17],[-4.87,4.87],[-4.72,4.72],[-4.61,4.61],[-4.5,4.5],[-4.35,4.35],[-4.01,4.01],[0.0,0.0],[0.0,-12.64],[0.0,-7.62],[0.0,-7.18],[0.0,-7.0],[0.0,-6.9],[0.0,-6.83],[0.0,-6.78],[0.0,-6.74],[0.0,-6.71],[0.0,-6.68],[0.0,-6.65],[0.0,-6.62],[0.0,-6.58],[0.0,-6.54],[0.0,-6.49],[0.0,-6.43],[0.0,-6.32],[0.0,-6.12],[0.0,-5.66],[0.0,0.0],[8.51,8.51],[5.17,5.17],[4.87,4.87],[4.72,4.72],[4.61,4.61],[4.5,4.5],[4.35,4.35],[4.01,4.01],[0.0,0.0],[11.75,0.0],[7.16,0.0],[6.59,0.0],[5.97,0.0]],"o":[[0.0,0.0],[0.0,-5.44],[0.0,-5.9],[0.0,-6.13],[0.0,-6.31],[0.0,-6.52],[0.0,-6.95],[0.0,-11.42],[0.0,0.0],[5.97,0.0],[6.59,0.0],[7.16,0.0],[11.75,0.0],[0.0,0.0],[4.01,-4.01],[4.35,-4.35],[4.5,-4.5],[4.61,-4.61],[4.72,-4.72],[4.87,-4.87],[5.17,-5.17],[8.51,-8.51],[0.0,0.0],[0.0,5.66],[0.0,6.12],[0.0,6.32],[0.0,6.43],[0.0,6.49],[0.0,6.54],[0.0,6.58],[0.0,6.62],[0.0,6.65],[0.0,6.68],[0.0,6.71],[0.0,6.74],[0.0,6.78],[0.0,6.83],[0.0,6.9],[0.0,7.0],[0.0,7.18],[0.0,7.62],[0.0,12.64],[0.0,0.0],[-4.01,-4.01],[-4.35,-4.35],[-4.5,-4.5],[-4.61,-4.61],[-4.72,-4.72],[-4.87,-4.87],[-5.17,-5.17],[-8.51,-8.51],[0.0,0.0],[-5.97,0.0],[-6.59,0.0],[-7.16,0.0],[-11.75,0.0]],"v":[[75.0,375.0],[75.0,356.26],[75.0,337.5],[75.0,318.75],[75.0,300.0],[75.0,281.25],[75.0,262.5],[75.0,243.74],[75.0,225.0],[94.99,225.0],[115.0,225.0],[135.0,225.0],[155.01,225.0],[175.0,225.0],[188.88,211.12],[202.77,197.23],[216.66,183.34],[230.55,169.45],[244.45,155.55],[258.34,141.66],[272.23,127.77],[286.12,113.88],[300.0,100.0],[300.0,119.95],[300.0,139.96],[300.0,159.97],[300.0,179.97],[300.0,199.99],[300.0,219.99],[300.0,239.99],[300.0,259.99],[300.0,280.0],[300.0,300.0],[300.0,320.0],[300.0,340.01],[300.0,360.01],[300.0,380.01],[300.0,400.01],[300.0,420.03],[300.0,440.03],[300.0,460.04],[300.0,480.05],[300.0,500.0],[286.12,486.12],[272.23,472.23],[258.34,458.34],[244.45,444.45],[230.55,430.55],[216.66,416.66],[202.77,402.77],[188.88,388.88],[175.0,375.0],[155.01,375.0],[135.0,375.0],[115.0,375.0],[94.99,375.0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.38,-1.11],[0.0,5.33],[0.0,3.2],[0.0,3.02],[0.0,2.94],[0.0,2.9],[0.0,2.87],[0.0,2.85],[0.0,2.84],[0.0,2.83],[0.0,2.82],[0.0,2.81],[0.0,2.8],[0.0,2.79],[0.0,2.78],[0.0,2.77],[0.0,2.76],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.69],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[-2.26,-1.25],[-2.17,-1.42],[-2.06,-1.58],[-1.94,-1.74],[-1.81,-1.89],[-1.66,-2.03],[-1.51,-2.15],[-1.36,-2.26],[-1.2,-2.32],[-1.04,-2.4],[-0.87,-2.47],[-0.7,-2.53],[-0.52,-2.58],[-0.34,-2.62],[-0.17,-2.64],[0.0,-2.65],[0.17,-2.56],[0.34,-2.55],[0.52,-2.53],[0.7,-2.49],[0.88,-2.44],[1.05,-2.38],[1.21,-2.31],[1.36,-2.23],[1.5,-2.1],[1.65,-1.99],[1.8,-1.87],[1.95,-1.73],[2.08,-1.58],[2.19,-1.43],[2.3,-1.27]],"o":[[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.69],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.76],[0.0,-2.77],[0.0,-2.78],[0.0,-2.79],[0.0,-2.8],[0.0,-2.81],[0.0,-2.82],[0.0,-2.83],[0.0,-2.84],[0.0,-2.85],[0.0,-2.87],[0.0,-2.9],[0.0,-2.94],[0.0,-3.02],[0.0,-3.2],[0.0,-5.33],[2.39,1.12],[2.3,1.28],[2.2,1.44],[2.08,1.59],[1.94,1.74],[1.8,1.88],[1.65,2.01],[1.49,2.12],[1.37,2.27],[1.21,2.35],[1.05,2.42],[0.87,2.48],[0.7,2.53],[0.52,2.56],[0.34,2.59],[0.17,2.6],[0.0,2.61],[-0.17,2.6],[-0.34,2.58],[-0.52,2.54],[-0.7,2.49],[-0.87,2.43],[-1.04,2.36],[-1.19,2.28],[-1.37,2.24],[-1.52,2.13],[-1.67,2.01],[-1.81,1.87],[-1.94,1.72],[-2.06,1.57],[-2.17,1.41],[-2.26,1.25]],"v":[[350.0,400.0],[350.0,391.63],[350.0,383.25],[350.0,374.85],[350.0,366.48],[350.0,358.08],[350.0,349.69],[350.0,341.31],[350.0,332.92],[350.0,324.54],[350.0,316.15],[350.0,307.76],[350.0,299.38],[350.0,290.99],[350.0,282.6],[350.0,274.21],[350.0,265.83],[350.0,257.44],[350.0,249.06],[350.0,240.67],[350.0,232.27],[350.0,223.9],[350.0,215.5],[350.0,207.12],[350.0,198.75],[356.98,202.31],[363.69,206.35],[370.08,210.88],[376.11,215.88],[381.74,221.33],[386.93,227.2],[391.66,233.44],[395.94,240.0],[399.78,246.88],[403.15,254.0],[406.03,261.33],[408.39,268.85],[410.21,276.51],[411.49,284.28],[412.25,292.13],[412.5,300.0],[412.25,307.76],[411.49,315.49],[410.19,323.15],[408.36,330.69],[406.0,338.09],[403.13,345.31],[399.76,352.3],[395.94,359.06],[391.65,365.58],[386.89,371.77],[381.69,377.59],[376.06,382.99],[370.03,387.95],[363.65,392.44],[356.95,396.46]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[4.09,-4.09],[2.49,-2.49],[2.34,-2.34],[2.26,-2.26],[2.2,-2.2],[2.12,-2.12],[1.95,-1.95],[0.0,0.0],[5.42,0.0],[3.3,0.0],[3.1,0.0],[3.0,0.0],[2.91,0.0],[2.8,0.0],[2.58,0.0],[0.0,0.0],[0.0,-4.97],[0.0,-3.03],[0.0,-2.83],[0.0,-2.68],[0.0,-2.45],[0.0,0.0],[-5.42,0.0],[-3.3,0.0],[-3.1,0.0],[-3.0,0.0],[-2.91,0.0],[-2.8,0.0],[-2.58,0.0],[0.0,0.0],[-4.09,-4.09],[-2.49,-2.49],[-2.34,-2.34],[-2.26,-2.26],[-2.2,-2.2],[-2.12,-2.12],[-1.95,-1.95],[0.0,0.0],[0.0,5.52],[0.0,3.32],[0.0,3.14],[0.0,3.06],[0.0,3.01],[0.0,2.98],[0.0,2.96],[0.0,2.94],[0.0,2.92],[0.0,2.91],[0.0,2.89],[0.0,2.87],[0.0,2.85],[0.0,2.82],[0.0,2.77],[0.0,2.68],[0.0,2.48]],"o":[[0.0,0.0],[-1.95,1.95],[-2.12,2.12],[-2.2,2.2],[-2.26,2.26],[-2.34,2.34],[-2.49,2.49],[-4.09,4.09],[0.0,0.0],[-2.58,0.0],[-2.8,0.0],[-2.91,0.0],[-3.0,0.0],[-3.1,0.0],[-3.3,0.0],[-5.42,0.0],[0.0,0.0],[0.0,2.45],[0.0,2.68],[0.0,2.83],[0.0,3.03],[0.0,4.97],[0.0,0.0],[2.58,0.0],[2.8,0.0],[2.91,0.0],[3.0,0.0],[3.1,0.0],[3.3,0.0],[5.42,0.0],[0.0,0.0],[1.95,1.95],[2.12,2.12],[2.2,2.2],[2.26,2.26],[2.34,2.34],[2.49,2.49],[4.09,4.09],[0.0,0.0],[0.0,-2.48],[0.0,-2.68],[0.0,-2.77],[0.0,-2.82],[0.0,-2.85],[0.0,-2.87],[0.0,-2.89],[0.0,-2.91],[0.0,-2.92],[0.0,-2.94],[0.0,-2.96],[0.0,-2.98],[0.0,-3.01],[0.0,-3.06],[0.0,-3.14],[0.0,-3.32],[0.0,-5.52]],"v":[[250.0,221.25],[243.28,227.97],[236.56,234.69],[229.85,241.4],[223.12,248.12],[216.4,254.85],[209.69,261.56],[202.97,268.28],[196.25,275.0],[187.35,275.0],[178.44,275.0],[169.53,275.0],[160.62,275.0],[151.72,275.0],[142.81,275.0],[133.9,275.0],[125.0,275.0],[125.0,283.33],[125.0,291.67],[125.0,300.0],[125.0,308.33],[125.0,316.67],[125.0,325.0],[133.9,325.0],[142.81,325.0],[151.72,325.0],[160.62,325.0],[169.53,325.0],[178.44,325.0],[187.35,325.0],[196.25,325.0],[202.97,331.72],[209.69,338.44],[216.4,345.15],[223.12,351.88],[229.85,358.6],[236.56,365.31],[243.28,372.03],[250.0,378.75],[250.0,370.01],[250.0,361.26],[250.0,352.51],[250.0,343.76],[250.0,335.01],[250.0,326.25],[250.0,317.5],[250.0,308.75],[250.0,300.0],[250.0,291.25],[250.0,282.5],[250.0,273.75],[250.0,264.99],[250.0,256.24],[250.0,247.49],[250.0,238.74],[250.0,229.99]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[4.09,4.09],[2.49,2.49],[2.34,2.34],[2.26,2.26],[2.2,2.2],[2.12,2.12],[1.95,1.95],[0.0,0.0],[5.42,0.0],[3.3,0.0],[3.1,0.0],[3.0,0.0],[2.91,0.0],[2.8,0.0],[2.58,0.0],[0.0,0.0],[0.0,4.97],[0.0,3.03],[0.0,2.83],[0.0,2.68],[0.0,2.45],[0.0,0.0],[-5.42,0.0],[-3.3,0.0],[-3.1,0.0],[-3.0,0.0],[-2.91,0.0],[-2.8,0.0],[-2.58,0.0],[0.0,0.0],[-4.09,4.09],[-2.49,2.49],[-2.34,2.34],[-2.26,2.26],[-2.2,2.2],[-2.12,2.12],[-1.95,1.95],[0.0,0.0],[0.0,-5.52],[0.0,-3.32],[0.0,-3.14],[0.0,-3.06],[0.0,-3.01],[0.0,-2.98],[0.0,-2.96],[0.0,-2.94],[0.0,-2.92],[0.0,-2.91],[0.0,-2.89],[0.0,-2.87],[0.0,-2.85],[0.0,-2.82],[0.0,-2.77],[0.0,-2.68],[0.0,-2.48]],"o":[[0.0,0.0],[-1.95,-1.95],[-2.12,-2.12],[-2.2,-2.2],[-2.26,-2.26],[-2.34,-2.34],[-2.49,-2.49],[-4.09,-4.09],[0.0,0.0],[-2.58,0.0],[-2.8,0.0],[-2.91,0.0],[-3.0,0.0],[-3.1,0.0],[-3.3,0.0],[-5.42,0.0],[0.0,0.0],[0.0,-2.45],[0.0,-2.68],[0.0,-2.83],[0.0,-3.03],[0.0,-4.97],[0.0,0.0],[2.58,0.0],[2.8,0.0],[2.91,0.0],[3.0,0.0],[3.1,0.0],[3.3,0.0],[5.42,0.0],[0.0,0.0],[1.95,-1.95],[2.12,-2.12],[2.2,-2.2],[2.26,-2.26],[2.34,-2.34],[2.49,-2.49],[4.09,-4.09],[0.0,0.0],[0.0,2.48],[0.0,2.68],[0.0,2.77],[0.0,2.82],[0.0,2.85],[0.0,2.87],[0.0,2.89],[0.0,2.91],[0.0,2.92],[0.0,2.94],[0.0,2.96],[0.0,2.98],[0.0,3.01],[0.0,3.06],[0.0,3.14],[0.0,3.32],[0.0,5.52]],"v":[[250.0,378.75],[243.28,372.03],[236.56,365.31],[229.85,358.6],[223.13,351.87],[216.4,345.15],[209.69,338.44],[202.97,331.72],[196.25,325.0],[187.35,325.0],[178.44,325.0],[169.53,325.0],[160.62,325.0],[151.72,325.0],[142.81,325.0],[133.9,325.0],[125.0,325.0],[125.0,316.67],[125.0,308.33],[125.0,300.0],[125.0,291.67],[125.0,283.33],[125.0,275.0],[133.9,275.0],[142.81,275.0],[151.72,275.0],[160.62,275.0],[169.53,275.0],[178.44,275.0],[187.35,275.0],[196.25,275.0],[202.97,268.28],[209.69,261.56],[216.4,254.85],[223.12,248.12],[229.85,241.4],[236.56,234.69],[243.28,227.97],[250.0,221.25],[250.0,229.99],[250.0,238.74],[250.0,247.49],[250.0,256.24],[250.0,264.99],[250.0,273.75],[250.0,282.5],[250.0,291.25],[250.0,300.0],[250.0,308.75],[250.0,317.5],[250.0,326.25],[250.0,335.01],[250.0,343.76],[250.0,352.51],[250.0,361.26],[250.0,370.01]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"plain","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/lib/core/media/desktop_video_probe.dart b/lib/core/media/desktop_video_probe.dart index 529abf4..d16f7b4 100644 --- a/lib/core/media/desktop_video_probe.dart +++ b/lib/core/media/desktop_video_probe.dart @@ -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 _durations = {}; static final Map _thumbs = {}; + static Future toolsAvailable() => _toolsAvailable(); + static Future _toolsAvailable() async { if (_hasTools != null) return _hasTools!; if (!supported) return _hasTools = false; @@ -79,6 +84,60 @@ class DesktopVideoProbe { return result; } + static Future 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; + final streams = (root['streams'] as List?) ?? const []; + Map? video; + var hasAudio = false; + for (final raw in streams) { + final s = raw as Map; + 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 frameAt(String path, int timeMs, int size) async { + if (!await _toolsAvailable()) return null; + return _grabFrame(path, size, (timeMs / 1000).toStringAsFixed(3)); + } + static Future thumbnail(String path, int size) async { final key = '$path@$size'; if (_thumbs.containsKey(key)) return _thumbs[key]; diff --git a/lib/core/media/video_transcoder.dart b/lib/core/media/video_transcoder.dart new file mode 100644 index 0000000..c249569 --- /dev/null +++ b/lib/core/media/video_transcoder.dart @@ -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? 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 ensureAvailable() async { + if (Platform.isAndroid) return true; + if (!DesktopVideoProbe.supported) return false; + _ffmpegReady = await DesktopVideoProbe.toolsAvailable(); + return _ffmpegReady; + } + + static Future probe(String path) async { + if (Platform.isAndroid) { + try { + final res = await _channel.invokeMapMethod('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> frames( + String path, + List timesMs, { + int size = 256, + bool precise = false, + }) async { + if (timesMs.isEmpty) return const []; + if (Platform.isAndroid) { + try { + final res = await _channel.invokeListMethod('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 = []; + for (final t in timesMs) { + out.add(await DesktopVideoProbe.frameAt(path, t, size)); + } + return out; + } + + static Future outputFile(String prefix) async { + final dir = await getTemporaryDirectory(); + return File( + p.join( + dir.path, + 'komet_${prefix}_${DateTime.now().microsecondsSinceEpoch}.mp4', + ), + ); + } + + static Future 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 cancel() async { + if (Platform.isAndroid) { + try { + await _channel.invokeMethod('editCancel'); + } catch (_) {} + return; + } + _desktopCancelled = true; + _desktopProcess?.kill(); + } + + static Future _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('editProgress'); + if (value != null && value >= 0) onProgress(value / 100); + } catch (_) {} + }); + try { + final ok = await _channel.invokeMethod('edit', _androidArgs(spec)); + return ok == true; + } catch (e) { + logger.w('VideoTranscoder.export: $e'); + return false; + } finally { + poll?.cancel(); + } + } + + static Map _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 + : [ + 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 _exportFfmpeg( + VideoExportSpec spec, + void Function(double)? onProgress, + ) async { + _desktopCancelled = false; + File? lut; + try { + final args = [ + '-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 = []; + 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 _writeCubeLut(List 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; + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index b2ad926..0d36585 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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 diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 35badb0..109fd7d 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -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 { final ValueNotifier> _selected = ValueNotifier({}); final Map> _thumbKeys = {}; final Map _edits = {}; + final Map _videoEdits = {}; + bool _videoEditorReady = false; + bool _exporting = false; final Set _tempFiles = {}; final Set _sentFiles = {}; final TextEditingController _captionCtrl = TextEditingController(); @@ -116,6 +123,9 @@ class _AttachmentSheetState extends State { } else { _loadGallery(); } + VideoTranscoder.ensureAvailable().then((ready) { + if (mounted && ready) setState(() => _videoEditorReady = true); + }); } @override @@ -164,15 +174,35 @@ class _AttachmentSheetState extends State { _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( + 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( hero: hero, @@ -216,13 +246,114 @@ class _AttachmentSheetState extends State { _openPreview(GalleryItem.fromFile(File(shot.path))); } - void _sendSelection({GalleryItem? fallback}) { + Future _exportVideos(List 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(0); + final navigator = Navigator.of(context, rootNavigator: true); + var cancelled = false; + setState(() => _exporting = true); + unawaited( + showGeneralDialog( + 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 _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 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( + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/editor_common.dart b/lib/frontend/widgets/attachment/editor_common.dart new file mode 100644 index 0000000..6081cb7 --- /dev/null +++ b/lib/frontend/widgets/attachment/editor_common.dart @@ -0,0 +1,2247 @@ +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 '../../../core/config/app_shape.dart'; +import '../../../l10n/app_localizations.dart'; +import '../custom_notification.dart'; +import '../small_spinner.dart'; +import 'photo_hero.dart'; + +const Color kEditorPanel = Color(0xFF0A0A0A); +const Color kEditorDrawPanel = Color(0xFF101010); +const Color kEditorBar = Color(0xFF1E1E1E); + +const List kPenWheel = [ + Color(0xFFFF3B30), + Color(0xFFFFCC00), + Color(0xFF34C759), + Color(0xFF00C7BE), + Color(0xFF2F8FFF), + Color(0xFFAF52DE), + Color(0xFFFF3B30), +]; + +class CropState { + final int quarterTurns; + final bool flipH; + final double straightenDeg; + final Rect cropNorm; + + const CropState({ + required this.quarterTurns, + required this.flipH, + required this.straightenDeg, + required this.cropNorm, + }); + + bool sameAs(CropState o) => + quarterTurns == o.quarterTurns && + flipH == o.flipH && + (straightenDeg - o.straightenDeg).abs() < 0.05 && + cropNorm == o.cropNorm; +} + +class CropGeometry { + final Size source; + final int quarterTurns; + final bool flipH; + final double straightenDeg; + + const CropGeometry({ + required this.source, + this.quarterTurns = 0, + this.flipH = false, + this.straightenDeg = 0, + }); + + double get phi => straightenDeg * math.pi / 180 - quarterTurns * math.pi / 2; + + Size get orientedSize => + quarterTurns.isOdd ? Size(source.height, source.width) : source; + + 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, + ); + } + + double baseScale(Size vp) { + final o = orientedSize; + if (o.isEmpty || vp.isEmpty) return 1; + const margin = 0.9; + return math.min(vp.width / o.width, vp.height / o.height) * margin; + } + + Rect fittedRect(Size vp) { + final o = orientedSize; + final base = baseScale(vp); + return Rect.fromCenter( + center: Offset(vp.width / 2, vp.height / 2), + width: o.width * base, + height: o.height * base, + ); + } + + double scaleFor(Size vp, Rect crop) { + final base = baseScale(vp); + final center = Offset(vp.width / 2, vp.height / 2); + final c = math.cos(-phi); + final s = math.sin(-phi); + var maxS = 0.0; + for (final corner in [ + crop.topLeft, + crop.topRight, + crop.bottomLeft, + crop.bottomRight, + ]) { + final rx = corner.dx - center.dx; + final ry = corner.dy - center.dy; + final lx = rx * c - ry * s; + final ly = rx * s + ry * c; + maxS = math.max( + maxS, + math.max(lx.abs() / (source.width / 2), ly.abs() / (source.height / 2)), + ); + } + return math.max(base, maxS); + } + + Matrix4 viewportMatrix(Size vp, Rect crop) { + final scale = scaleFor(vp, crop); + return Matrix4.identity() + ..translateByDouble(vp.width / 2, vp.height / 2, 0, 1) + ..multiply(flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity()) + ..rotateZ(phi) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-source.width / 2, -source.height / 2, 0, 1); + } + + Rect cropInRotated(Size vp, Rect crop) { + final scale = scaleFor(vp, crop); + final rotated = rotatedSize; + if (scale <= 0 || rotated.isEmpty) return const Rect.fromLTRB(0, 0, 1, 1); + final center = Offset(vp.width / 2, vp.height / 2); + final left = rotated.width / 2 + (crop.left - center.dx) / scale; + final top = rotated.height / 2 + (crop.top - center.dy) / scale; + final right = rotated.width / 2 + (crop.right - center.dx) / scale; + final bottom = rotated.height / 2 + (crop.bottom - center.dy) / scale; + return Rect.fromLTRB( + (left / rotated.width).clamp(0.0, 1.0), + (top / rotated.height).clamp(0.0, 1.0), + (right / rotated.width).clamp(0.0, 1.0), + (bottom / rotated.height).clamp(0.0, 1.0), + ); + } +} + +class CropView { + final double scale; + final Offset focus; + + const CropView({required this.scale, required this.focus}); + + static const double margin = 0.9; + + static CropView fit(Rect crop, Size vp) { + if (crop.isEmpty || vp.isEmpty) { + return CropView(scale: 1, focus: crop.center); + } + return CropView( + scale: math.min( + vp.width * margin / crop.width, + vp.height * margin / crop.height, + ), + focus: crop.center, + ); + } + + static CropView lerp(CropView a, CropView b, double t) => CropView( + scale: a.scale + (b.scale - a.scale) * t, + focus: Offset.lerp(a.focus, b.focus, t)!, + ); + + Matrix4 matrix(Size vp) => Matrix4.identity() + ..translateByDouble(vp.width / 2, vp.height / 2, 0, 1) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-focus.dx, -focus.dy, 0, 1); + + Offset toDisplay(Offset logical, Size vp) => + Offset(vp.width / 2, vp.height / 2) + (logical - focus) * scale; + + Offset toLogical(Offset display, Size vp) => + focus + (display - Offset(vp.width / 2, vp.height / 2)) / scale; + + Rect rect(Rect logical, Size vp) => Rect.fromPoints( + toDisplay(logical.topLeft, vp), + toDisplay(logical.bottomRight, vp), + ); +} + +class CropWorkspace extends StatefulWidget { + final Size imageSize; + final Widget Function(BuildContext context, Matrix4 matrix) imageBuilder; + final CropState? initialState; + final Future Function( + CropState state, + Size viewport, + bool changed, + bool identity, + ) + onApply; + + const CropWorkspace({ + super.key, + required this.imageSize, + required this.imageBuilder, + required this.onApply, + this.initialState, + }); + + @override + State createState() => _CropWorkspaceState(); +} + +class _CropWorkspaceState extends State + with SingleTickerProviderStateMixin { + int _quarterTurns = 0; + bool _flipH = false; + double _straightenDeg = 0; + Rect? _crop; + Size _viewport = Size.zero; + bool _stateApplied = false; + bool _busy = false; + int _handle = -1; + + CropView _view = const CropView(scale: 1, focus: Offset.zero); + CropView? _viewFrom; + CropView? _viewTo; + + late final AnimationController _zoom = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + ); + late final Animation _curve = CurvedAnimation( + parent: _zoom, + curve: Curves.easeOutCubic, + ); + final ValueNotifier _rev = ValueNotifier(0); + + @override + void initState() { + super.initState(); + final initial = widget.initialState; + if (initial != null) { + _quarterTurns = initial.quarterTurns; + _flipH = initial.flipH; + _straightenDeg = initial.straightenDeg; + } + _zoom.addStatusListener((status) { + if (status != AnimationStatus.completed) return; + final target = _viewTo; + if (target != null) _view = target; + _viewFrom = null; + _viewTo = null; + }); + } + + @override + void dispose() { + _zoom.dispose(); + _rev.dispose(); + super.dispose(); + } + + CropGeometry get _geometry => CropGeometry( + source: widget.imageSize, + quarterTurns: _quarterTurns, + flipH: _flipH, + straightenDeg: _straightenDeg, + ); + + CropView get _liveView { + final from = _viewFrom; + final to = _viewTo; + if (from == null || to == null) return _view; + return CropView.lerp(from, to, _curve.value); + } + + void _setCrop(Rect r) { + _crop = r; + _rev.value++; + } + + void _animateTo(CropView target) { + _viewFrom = _liveView; + _viewTo = target; + _zoom.forward(from: 0); + } + + void _ensureCrop(Size vp) { + if (_crop != null && _viewport == vp) return; + _viewport = vp; + final initial = widget.initialState; + if (initial != null && !_stateApplied) { + _stateApplied = true; + _crop = Rect.fromLTRB( + initial.cropNorm.left * vp.width, + initial.cropNorm.top * vp.height, + initial.cropNorm.right * vp.width, + initial.cropNorm.bottom * vp.height, + ); + } else { + _crop = _geometry.fittedRect(vp); + } + _view = CropView.fit(_crop!, vp); + _viewFrom = null; + _viewTo = null; + } + + void _refit() { + final crop = _crop; + if (crop == null || _viewport == Size.zero) return; + _animateTo(CropView.fit(crop, _viewport)); + } + + void _reset() { + setState(() { + _quarterTurns = 0; + _flipH = false; + _straightenDeg = 0; + _crop = _geometry.fittedRect(_viewport); + }); + _refit(); + } + + void _rotate90() { + setState(() { + _quarterTurns = (_quarterTurns + 1) % 4; + _straightenDeg = 0; + _crop = _geometry.fittedRect(_viewport); + }); + _refit(); + } + + void _flip() => setState(() => _flipH = !_flipH); + + bool get _isFullCrop { + final c = _crop; + if (c == null) return true; + final f = _geometry.fittedRect(_viewport); + return (c.left - f.left).abs() < 1 && + (c.top - f.top).abs() < 1 && + (c.right - f.right).abs() < 1 && + (c.bottom - f.bottom).abs() < 1; + } + + CropState _currentState(Size vp, Rect crop) => CropState( + quarterTurns: _quarterTurns, + flipH: _flipH, + straightenDeg: _straightenDeg, + cropNorm: Rect.fromLTRB( + crop.left / vp.width, + crop.top / vp.height, + crop.right / vp.width, + crop.bottom / vp.height, + ), + ); + + Future _done() async { + if (_busy) return; + final crop = _crop; + final vp = _viewport; + if (crop == null || vp == Size.zero) { + Navigator.of(context).pop(); + return; + } + final state = _currentState(vp, crop); + final initial = widget.initialState; + final identity = + _quarterTurns == 0 && !_flipH && _straightenDeg == 0 && _isFullCrop; + final changed = initial != null ? !state.sameAs(initial) : !identity; + setState(() => _busy = true); + final result = await widget.onApply(state, vp, changed, identity); + if (!mounted) return; + if (result == null && changed) { + setState(() => _busy = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyFailed, + ); + return; + } + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + Expanded(child: _buildViewport()), + _buildTools(), + _buildActions(), + ], + ), + if (_busy) const BusyOverlay(), + ], + ), + ), + ); + } + + Widget _buildViewport() { + return LayoutBuilder( + builder: (context, constraints) { + final vp = constraints.biggest; + _ensureCrop(vp); + return Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onPanStart(d.localPosition, vp), + onPanUpdate: (d) => _onPanUpdate(d.delta, vp), + onPanEnd: (_) => _onPanEnd(), + onPanCancel: _onPanEnd, + child: PhotoHeroFade( + child: AnimatedBuilder( + animation: Listenable.merge([_rev, _curve]), + builder: (context, _) { + final view = _liveView; + final crop = _crop!; + final matrix = view.matrix(vp) + ..multiply(_geometry.viewportMatrix(vp, crop)); + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + widget.imageBuilder(context, matrix), + CustomPaint( + painter: CropChromePainter(view.rect(crop, vp)), + ), + ], + ), + ); + }, + ), + ), + ), + IgnorePointer( + child: Center( + child: FractionallySizedBox( + widthFactor: CropView.margin, + heightFactor: CropView.margin, + child: const PhotoHeroAnchor(child: SizedBox.expand()), + ), + ), + ), + ], + ); + }, + ); + } + + void _onPanStart(Offset pos, Size vp) { + final crop = _crop; + if (crop == null) return; + _handle = hitCropHandle(pos, _liveView.rect(crop, vp)); + } + + void _onPanUpdate(Offset delta, Size vp) { + final crop = _crop; + if (crop == null || _handle < 0) return; + final view = _liveView; + _setCrop( + moveCropHandle( + crop, + _handle, + delta / view.scale, + _geometry.fittedRect(vp), + ), + ); + } + + void _onPanEnd() { + if (_handle < 0) return; + _handle = -1; + _refit(); + } + + Widget _buildTools() { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + IconButton( + onPressed: _flip, + icon: Icon( + Symbols.flip, + color: _flipH ? MediaAccent.of(context) : Colors.white, + ), + tooltip: l10n.photoEditorFlipTooltip, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _rev, + builder: (context, _, _) => StraightenRuler( + value: _straightenDeg, + onChanged: (v) { + _straightenDeg = v; + _rev.value++; + }, + ), + ), + ), + IconButton( + onPressed: _rotate90, + icon: const Icon( + Symbols.rotate_90_degrees_ccw, + color: Colors.white, + ), + tooltip: l10n.photoEditorRotateTooltip, + ), + ], + ), + ); + } + + Widget _buildActions() { + final l10n = AppLocalizations.of(context)!; + return Container( + color: kEditorPanel, + padding: const EdgeInsets.symmetric(vertical: 6), + child: 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: _reset, + child: Text( + l10n.photoEditorReset, + 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, + ), + ), + ), + ], + ), + ); + } +} + +class CropChromePainter extends CustomPainter { + final Rect crop; + + CropChromePainter(this.crop); + + @override + void paint(Canvas canvas, Size size) => paintCropChrome(canvas, size, crop); + + @override + bool shouldRepaint(covariant CropChromePainter old) => old.crop != crop; +} + +class MatrixImagePainter extends CustomPainter { + final ui.Image image; + final Matrix4 matrix; + + MatrixImagePainter(this.image, this.matrix); + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.transform(matrix.storage); + canvas.drawImage( + image, + Offset.zero, + Paint()..filterQuality = FilterQuality.medium, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant MatrixImagePainter old) => + old.matrix != matrix || old.image != image; +} + +class CropPainter extends CustomPainter { + final ui.Image image; + final Matrix4 matrix; + final Rect crop; + + CropPainter({required this.image, required this.matrix, required this.crop}); + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.transform(matrix.storage); + canvas.drawImage( + image, + Offset.zero, + Paint()..filterQuality = FilterQuality.medium, + ); + canvas.restore(); + paintCropChrome(canvas, size, crop); + } + + @override + bool shouldRepaint(covariant CropPainter old) => + old.matrix != matrix || old.crop != crop || old.image != image; +} + +void paintCropChrome(Canvas canvas, Size size, Rect crop) { + canvas.drawPath( + Path.combine( + PathOperation.difference, + Path()..addRect(Offset.zero & size), + Path()..addRect(crop), + ), + Paint()..color = Colors.black.withValues(alpha: 0.55), + ); + + final grid = Paint() + ..color = Colors.white.withValues(alpha: 0.4) + ..strokeWidth = 0.7; + for (var i = 1; i < 3; i++) { + final x = crop.left + crop.width * i / 3; + final y = crop.top + crop.height * i / 3; + canvas.drawLine(Offset(x, crop.top), Offset(x, crop.bottom), grid); + canvas.drawLine(Offset(crop.left, y), Offset(crop.right, y), grid); + } + + final border = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + canvas.drawRect(crop, border); + + final bracket = Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + const len = 20.0; + void corner(Offset o, double dx, double dy) { + canvas.drawLine(o, o.translate(dx, 0), bracket); + canvas.drawLine(o, o.translate(0, dy), bracket); + } + + corner(crop.topLeft, len, len); + corner(crop.topRight, -len, len); + corner(crop.bottomLeft, len, -len); + corner(crop.bottomRight, -len, -len); +} + +int hitCropHandle(Offset pt, Rect c) { + const r = 34.0; + final corners = [c.topLeft, c.topRight, c.bottomRight, c.bottomLeft]; + for (var i = 0; i < 4; i++) { + if ((pt - corners[i]).distance < r) return i; + } + final insideV = pt.dy > c.top - r && pt.dy < c.bottom + r; + final insideH = pt.dx > c.left - r && pt.dx < c.right + r; + if ((pt.dx - c.left).abs() < r && insideV) return 4; + if ((pt.dx - c.right).abs() < r && insideV) return 5; + if ((pt.dy - c.top).abs() < r && insideH) return 6; + if ((pt.dy - c.bottom).abs() < r && insideH) return 7; + if (c.contains(pt)) return 8; + return -1; +} + +Rect moveCropHandle(Rect c, int handle, Offset delta, Rect bounds) { + const minSize = 64.0; + if (handle == 8) { + var nl = c.left + delta.dx; + var nt = c.top + delta.dy; + var nr = c.right + delta.dx; + var nb = c.bottom + delta.dy; + if (nl < bounds.left) { + nr += bounds.left - nl; + nl = bounds.left; + } + if (nt < bounds.top) { + nb += bounds.top - nt; + nt = bounds.top; + } + if (nr > bounds.right) { + nl -= nr - bounds.right; + nr = bounds.right; + } + if (nb > bounds.bottom) { + nt -= nb - bounds.bottom; + nb = bounds.bottom; + } + return Rect.fromLTRB(nl, nt, nr, nb); + } + + var l = c.left; + var t = c.top; + var r = c.right; + var bo = c.bottom; + switch (handle) { + case 0: + l += delta.dx; + t += delta.dy; + case 1: + r += delta.dx; + t += delta.dy; + case 2: + r += delta.dx; + bo += delta.dy; + case 3: + l += delta.dx; + bo += delta.dy; + case 4: + l += delta.dx; + case 5: + r += delta.dx; + case 6: + t += delta.dy; + case 7: + bo += delta.dy; + } + l = l.clamp(bounds.left, math.max(bounds.left, r - minSize)); + t = t.clamp(bounds.top, math.max(bounds.top, bo - minSize)); + r = r.clamp(math.min(bounds.right, l + minSize), bounds.right); + bo = bo.clamp(math.min(bounds.bottom, t + minSize), bounds.bottom); + return Rect.fromLTRB(l, t, r, bo); +} + +class StraightenRuler extends StatelessWidget { + final double value; + final ValueChanged onChanged; + + const StraightenRuler({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (d) { + onChanged((value - d.delta.dx * 0.22).clamp(-45.0, 45.0)); + }, + onDoubleTap: () => onChanged(0), + child: SizedBox( + height: 56, + child: CustomPaint( + painter: _RulerPainter(value, MediaAccent.of(context)), + ), + ), + ); + } +} + +class _RulerPainter extends CustomPainter { + final double value; + final Color accent; + + _RulerPainter(this.value, this.accent); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + const pxPerDeg = 6.0; + final baseY = size.height - 6; + + final tick = Paint()..strokeWidth = 1; + for (var deg = -60; deg <= 60; deg++) { + final x = cx + (deg - value) * pxPerDeg; + if (x < 0 || x > size.width) continue; + final major = deg % 5 == 0; + tick.color = Colors.white.withValues(alpha: major ? 0.85 : 0.4); + final h = major ? 14.0 : 8.0; + canvas.drawLine(Offset(x, baseY - h), Offset(x, baseY), tick); + } + + final tp = TextPainter( + text: TextSpan( + text: '${value.toStringAsFixed(1).replaceAll('.', ',')}°', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(cx - tp.width / 2, 0)); + + canvas.drawLine( + Offset(cx, baseY - 18), + Offset(cx, baseY + 2), + Paint() + ..color = accent + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _RulerPainter old) => + old.value != value || old.accent != accent; +} + +enum DrawTool { pen, marker, neon, eraser } + +enum ShapeKind { circle, rectangle, star, cloud, arrow } + +enum EditTab { draw, stickers, text } + +sealed class EditMark {} + +class StrokeMark extends EditMark { + final List points; + final Color color; + final double width; + final DrawTool tool; + + StrokeMark({ + required this.points, + required this.color, + required this.width, + required this.tool, + }); +} + +class ShapeMark extends EditMark { + final ShapeKind kind; + final Offset start; + final Offset end; + final Color color; + final double width; + + ShapeMark({ + required this.kind, + required this.start, + required this.end, + required this.color, + required this.width, + }); +} + +class TextMark extends EditMark { + String text; + Offset position; + Color color; + double fontSize; + double rotation; + + TextMark({ + required this.text, + required this.position, + required this.color, + required this.fontSize, + this.rotation = 0, + }); +} + +class MarkupEditor extends StatefulWidget { + final Widget background; + final double aspectRatio; + final List initialMarks; + final Future Function(List marks, Size canvas) onApply; + + const MarkupEditor({ + super.key, + required this.background, + required this.aspectRatio, + required this.onApply, + this.initialMarks = const [], + }); + + @override + State createState() => _MarkupEditorState(); +} + +class _MarkupEditorState extends State { + final GlobalKey _boundaryKey = GlobalKey(); + final ValueNotifier _canvasRev = ValueNotifier(0); + late final List _marks = [...widget.initialMarks]; + StrokeMark? _liveStroke; + ShapeMark? _liveShape; + TextMark? _draggingText; + + DrawTool _tool = DrawTool.pen; + Color _color = Colors.white; + double _width = 8; + TextMark? _selectedText; + bool _resizingText = false; + double _resizeBaseSize = 0; + double _resizeBaseDist = 1; + double _resizeBaseRotation = 0; + double _resizeBaseAngle = 0; + ShapeKind? _shapeMode; + EditTab _tab = EditTab.draw; + bool _paletteOpen = false; + bool _shapesOpen = false; + bool _baking = false; + + @override + void dispose() { + _canvasRev.dispose(); + super.dispose(); + } + + void _bumpCanvas() => _canvasRev.value++; + + void _undo() { + if (_marks.isEmpty) return; + if (identical(_marks.last, _selectedText)) _selectedText = null; + setState(() => _marks.removeLast()); + } + + void _clearAll() { + if (_marks.isEmpty) return; + _selectedText = null; + setState(_marks.clear); + } + + void _onPanStart(Offset pos) { + if (_tab == EditTab.text) { + final sel = _selectedText; + if (sel != null && _nearHandle(sel, pos)) { + final v = pos - sel.position; + _resizingText = true; + _resizeBaseSize = sel.fontSize; + _resizeBaseDist = math.max(8, v.distance); + _resizeBaseRotation = sel.rotation; + _resizeBaseAngle = math.atan2(v.dy, v.dx); + return; + } + final hit = _hitText(pos); + _draggingText = hit; + if (hit != null && !identical(hit, _selectedText)) { + _selectedText = hit; + _bumpCanvas(); + } + return; + } + final shape = _shapeMode; + if (shape != null) { + _liveShape = ShapeMark( + kind: shape, + start: pos, + end: pos, + color: _color, + width: _width, + ); + } else { + _liveStroke = StrokeMark( + points: [pos], + color: _color, + width: _width, + tool: _tool, + ); + } + _bumpCanvas(); + } + + void _onPanUpdate(Offset pos) { + if (_tab == EditTab.text) { + if (_resizingText) { + final sel = _selectedText; + if (sel != null) { + final v = pos - sel.position; + final angle = math.atan2(v.dy, v.dx); + sel.fontSize = (_resizeBaseSize * v.distance / _resizeBaseDist).clamp( + 10.0, + 200.0, + ); + sel.rotation = _resizeBaseRotation + (angle - _resizeBaseAngle); + _bumpCanvas(); + } + return; + } + final t = _draggingText; + if (t != null) { + t.position = pos; + _bumpCanvas(); + } + return; + } + final shape = _liveShape; + if (shape != null) { + _liveShape = ShapeMark( + kind: shape.kind, + start: shape.start, + end: pos, + color: shape.color, + width: shape.width, + ); + _bumpCanvas(); + } else if (_liveStroke != null) { + final pts = _liveStroke!.points; + if (pts.isEmpty || (pos - pts.last).distance >= 2.0) { + pts.add(pos); + _bumpCanvas(); + } + } + } + + void _onPanEnd() { + if (_tab == EditTab.text) { + _resizingText = false; + _draggingText = null; + return; + } + final shape = _liveShape; + if (shape != null) { + if ((shape.end - shape.start).distance > 4) _marks.add(shape); + setState(() { + _liveShape = null; + _shapeMode = null; + }); + } else if (_liveStroke != null) { + if (_liveStroke!.points.isNotEmpty) _marks.add(_liveStroke!); + setState(() => _liveStroke = null); + } + } + + TextMark? _hitText(Offset pos) { + for (final m in _marks.reversed) { + if (m is! TextMark) continue; + final local = _toLocal(pos, m); + final box = textMarkSize(m); + if (local.dx.abs() <= box.width / 2 && local.dy.abs() <= box.height / 2) { + return m; + } + } + return null; + } + + Offset _toLocal(Offset pos, TextMark t) { + final v = pos - t.position; + final c = math.cos(-t.rotation); + final s = math.sin(-t.rotation); + return Offset(v.dx * c - v.dy * s, v.dx * s + v.dy * c); + } + + bool _nearHandle(TextMark t, Offset pos) { + final (left, right) = handlePositions(t); + return (pos - left).distance < 26 || (pos - right).distance < 26; + } + + Future _addText() async { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final String? text; + try { + text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + shape: AppShape.dialogBorder, + title: Text( + l10n.photoEditorTextDialogTitle, + style: const TextStyle(color: Colors.white), + ), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(color: Colors.white), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: l10n.photoEditorTextDialogHint, + hintStyle: const TextStyle(color: Colors.white38), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.spoofDialogCancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: Text(l10n.photoEditorOk), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + if (text == null || text.trim().isEmpty || !mounted) return; + final ro = _boundaryKey.currentContext?.findRenderObject(); + final size = ro is RenderBox ? ro.size : const Size(300, 300); + final mark = TextMark( + text: text.trim(), + position: Offset(size.width / 2, size.height / 2), + color: _color, + fontSize: 34, + ); + setState(() { + _marks.add(mark); + _selectedText = mark; + }); + } + + Future _apply() async { + if (_baking) return; + final ro = _boundaryKey.currentContext?.findRenderObject(); + final canvas = ro is RenderBox && !ro.size.isEmpty + ? ro.size + : const Size(300, 300); + setState(() => _baking = true); + final result = await widget.onApply(_marks, canvas); + if (!mounted) return; + if (result == null && _marks.isNotEmpty) { + setState(() => _baking = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyChangesFailed, + ); + return; + } + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Column( + children: [ + _buildTopBar(), + Expanded(child: _buildCanvas()), + _buildBottomPanel(), + ], + ), + if (_tab == EditTab.draw) _buildSideSlider(), + if (_baking) const BusyOverlay(), + ], + ), + ); + } + + Widget _buildTopBar() { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + IconButton( + onPressed: _marks.isEmpty ? null : _undo, + icon: const Icon(Symbols.undo), + color: Colors.white, + disabledColor: Colors.white24, + ), + const Spacer(), + TextButton( + onPressed: _marks.isEmpty ? null : _clearAll, + child: Text( + AppLocalizations.of(context)!.photoEditorClearAll, + style: TextStyle( + color: _marks.isEmpty ? Colors.white24 : Colors.white, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCanvas() { + final aspect = widget.aspectRatio; + return Center( + child: AspectRatio( + aspectRatio: aspect <= 0 ? 1.0 : aspect, + child: ValueListenableBuilder( + valueListenable: _canvasRev, + child: widget.background, + builder: (context, _, image) { + final selected = _tab == EditTab.text ? _selectedText : null; + return Stack( + fit: StackFit.expand, + children: [ + PhotoHeroTarget( + child: RepaintBoundary( + key: _boundaryKey, + child: Stack( + fit: StackFit.expand, + children: [ + image!, + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onPanStart(d.localPosition), + onPanUpdate: (d) => _onPanUpdate(d.localPosition), + onPanEnd: (_) => _onPanEnd(), + child: CustomPaint( + painter: DrawingPainter( + marks: _marks, + live: _liveStroke ?? _liveShape, + ), + ), + ), + ), + ], + ), + ), + ), + if (selected != null) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: SelectionPainter( + selected, + MediaAccent.of(context), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildSideSlider() { + return Positioned( + left: 2, + top: 0, + bottom: 0, + child: Center( + child: SizedBox( + height: 220, + child: RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbColor: Colors.white, + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), + ), + child: Slider( + min: 2, + max: 40, + value: _width, + onChanged: (v) => setState(() => _width = v), + ), + ), + ), + ), + ), + ); + } + + Widget _buildBottomPanel() { + return Container( + color: kEditorDrawPanel, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_paletteOpen) _buildColorPicker(), + if (_shapesOpen && _tab == EditTab.draw) _buildShapesRow(), + _buildToolbar(), + const SizedBox(height: 2), + _buildTabs(), + ], + ), + ), + ); + } + + Widget _buildToolbar() { + switch (_tab) { + case EditTab.draw: + return _buildDrawToolbar(); + case EditTab.text: + return _buildTextToolbar(); + case EditTab.stickers: + return const SizedBox(height: 56); + } + } + + Widget _buildDrawToolbar() { + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 10), + _buildColorButton(), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildToolButton(DrawTool.pen, Symbols.edit), + _buildToolButton(DrawTool.marker, Symbols.ink_highlighter), + _buildToolButton(DrawTool.neon, Symbols.auto_awesome), + _buildToolButton(DrawTool.eraser, Symbols.ink_eraser), + ], + ), + ), + IconButton( + onPressed: () => setState(() { + _shapesOpen = !_shapesOpen; + _paletteOpen = false; + }), + icon: Icon( + Symbols.add, + color: _shapeMode != null ? _color : Colors.white, + ), + ), + const SizedBox(width: 8), + ], + ), + ); + } + + Widget _buildTextToolbar() { + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 10), + _buildColorButton(), + const SizedBox(width: 14), + TextButton.icon( + onPressed: _addText, + icon: const Icon(Symbols.add, color: Colors.white), + label: Text( + AppLocalizations.of(context)!.photoEditorAddText, + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + ), + const Spacer(), + ], + ), + ); + } + + Widget _buildColorButton() { + return GestureDetector( + onTap: () => setState(() { + _paletteOpen = !_paletteOpen; + _shapesOpen = false; + }), + child: Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: SweepGradient(colors: kPenWheel), + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _color, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildToolButton(DrawTool tool, IconData icon) { + final selected = _shapeMode == null && _tool == tool; + return GestureDetector( + onTap: () => setState(() { + _tool = tool; + _shapeMode = null; + _shapesOpen = false; + }), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? Colors.white.withValues(alpha: 0.18) + : Colors.transparent, + ), + child: Icon( + icon, + color: selected ? Colors.white : Colors.white60, + size: 24, + ), + ), + ); + } + + Widget _buildColorPicker() { + return ColorPicker( + color: _color, + onChanged: (c) => setState(() { + _color = c; + if (_tab == EditTab.text) _selectedText?.color = c; + }), + ); + } + + Widget _buildShapesRow() { + const shapes = <(ShapeKind, IconData)>[ + (ShapeKind.circle, Symbols.circle), + (ShapeKind.rectangle, Symbols.rectangle), + (ShapeKind.star, Symbols.star), + (ShapeKind.cloud, Symbols.cloud), + (ShapeKind.arrow, Symbols.north_east), + ]; + return SizedBox( + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final (kind, icon) in shapes) + IconButton( + onPressed: () => setState(() { + _shapeMode = kind; + _shapesOpen = false; + }), + icon: Icon( + icon, + color: _shapeMode == kind ? _color : Colors.white, + ), + ), + ], + ), + ); + } + + Widget _buildTabs() { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + height: 48, + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Symbols.close, color: Colors.white), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTab(l10n.photoEditorTabDraw, EditTab.draw), + _buildTab( + l10n.photoEditorTabStickers, + EditTab.stickers, + disabled: true, + ), + _buildTab(l10n.photoEditorTabText, EditTab.text), + ], + ), + ), + IconButton( + onPressed: _baking ? null : _apply, + icon: const Icon(Symbols.check, color: Colors.white), + ), + ], + ), + ); + } + + Widget _buildTab(String label, EditTab tab, {bool disabled = false}) { + final selected = _tab == tab; + return GestureDetector( + onTap: disabled + ? null + : () => setState(() { + _tab = tab; + _paletteOpen = false; + _shapesOpen = false; + if (tab != EditTab.draw) _shapeMode = null; + }), + child: Text( + label, + style: TextStyle( + color: disabled + ? Colors.white24 + : (selected ? Colors.white : Colors.white60), + fontSize: 14, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class DrawingPainter extends CustomPainter { + final List marks; + final EditMark? live; + + DrawingPainter({required this.marks, this.live}); + + @override + void paint(Canvas canvas, Size size) => paintMarks(canvas, size); + + void paintMarks(Canvas canvas, Size size) { + final needsLayer = _hasEraser(); + if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint()); + for (final m in marks) { + _paintMark(canvas, m); + } + final l = live; + if (l != null) _paintMark(canvas, l); + if (needsLayer) canvas.restore(); + } + + bool _hasEraser() { + for (final m in marks) { + if (m is StrokeMark && m.tool == DrawTool.eraser) return true; + } + final l = live; + return l is StrokeMark && l.tool == DrawTool.eraser; + } + + void _paintMark(Canvas canvas, EditMark m) { + switch (m) { + case StrokeMark s: + _paintStroke(canvas, s); + case ShapeMark sh: + _paintShape(canvas, sh); + case TextMark t: + _paintText(canvas, t); + } + } + + void _paintStroke(Canvas canvas, StrokeMark s) { + final paint = Paint() + ..color = s.color + ..strokeWidth = s.width + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + switch (s.tool) { + case DrawTool.pen: + break; + case DrawTool.marker: + paint.color = s.color.withValues(alpha: 0.4); + paint.strokeWidth = s.width * 1.6; + paint.strokeCap = StrokeCap.square; + case DrawTool.neon: + final glow = Paint() + ..color = s.color.withValues(alpha: 0.7) + ..strokeWidth = s.width * 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + _drawStrokeGeometry(canvas, s, glow); + paint.color = Colors.white; + case DrawTool.eraser: + paint.blendMode = BlendMode.clear; + } + + _drawStrokeGeometry(canvas, s, paint); + } + + void _drawStrokeGeometry(Canvas canvas, StrokeMark s, Paint paint) { + if (s.points.length < 2) { + final dot = Paint() + ..color = paint.color + ..blendMode = paint.blendMode + ..maskFilter = paint.maskFilter + ..style = PaintingStyle.fill; + canvas.drawCircle(s.points.first, paint.strokeWidth / 2, dot); + return; + } + final path = Path()..moveTo(s.points.first.dx, s.points.first.dy); + for (var i = 1; i < s.points.length; i++) { + path.lineTo(s.points[i].dx, s.points[i].dy); + } + canvas.drawPath(path, paint); + } + + void _paintShape(Canvas canvas, ShapeMark sh) { + final paint = Paint() + ..color = sh.color + ..strokeWidth = sh.width + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final rect = Rect.fromPoints(sh.start, sh.end); + switch (sh.kind) { + case ShapeKind.circle: + canvas.drawOval(rect, paint); + case ShapeKind.rectangle: + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(10)), + paint, + ); + case ShapeKind.star: + canvas.drawPath(_starPath(rect), paint); + case ShapeKind.cloud: + canvas.drawPath(_cloudPath(rect), paint); + case ShapeKind.arrow: + _paintArrow(canvas, sh.start, sh.end, paint); + } + } + + Path _starPath(Rect rect) { + final cx = rect.center.dx; + final cy = rect.center.dy; + final outer = math.min(rect.width.abs(), rect.height.abs()) / 2; + final inner = outer * 0.45; + final path = Path(); + for (var i = 0; i < 10; i++) { + final r = i.isEven ? outer : inner; + final angle = -math.pi / 2 + i * math.pi / 5; + final x = cx + r * math.cos(angle); + final y = cy + r * math.sin(angle); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + path.close(); + return path; + } + + Path _cloudPath(Rect rect) { + final w = rect.width; + final h = rect.height; + Offset pt(double nx, double ny) => + Offset(rect.left + nx * w, rect.top + ny * h); + final path = Path()..moveTo(pt(0.25, 0.78).dx, pt(0.25, 0.78).dy); + path + ..cubicTo( + pt(0.0, 0.78).dx, + pt(0.0, 0.78).dy, + pt(0.0, 0.45).dx, + pt(0.0, 0.45).dy, + pt(0.22, 0.42).dx, + pt(0.22, 0.42).dy, + ) + ..cubicTo( + pt(0.2, 0.12).dx, + pt(0.2, 0.12).dy, + pt(0.56, 0.08).dx, + pt(0.56, 0.08).dy, + pt(0.62, 0.36).dx, + pt(0.62, 0.36).dy, + ) + ..cubicTo( + pt(0.86, 0.24).dx, + pt(0.86, 0.24).dy, + pt(1.02, 0.5).dx, + pt(1.02, 0.5).dy, + pt(0.8, 0.6).dx, + pt(0.8, 0.6).dy, + ) + ..cubicTo( + pt(1.02, 0.66).dx, + pt(1.02, 0.66).dy, + pt(0.96, 0.9).dx, + pt(0.96, 0.9).dy, + pt(0.74, 0.8).dx, + pt(0.74, 0.8).dy, + ) + ..cubicTo( + pt(0.7, 0.98).dx, + pt(0.7, 0.98).dy, + pt(0.34, 0.98).dx, + pt(0.34, 0.98).dy, + pt(0.25, 0.78).dx, + pt(0.25, 0.78).dy, + ) + ..close(); + return path; + } + + void _paintArrow(Canvas canvas, Offset start, Offset end, Paint paint) { + canvas.drawLine(start, end, paint); + final angle = math.atan2(end.dy - start.dy, end.dx - start.dx); + final headLen = math.max(paint.strokeWidth * 4, 18.0); + const headAngle = math.pi / 7; + final p1 = + end - + Offset(math.cos(angle - headAngle), math.sin(angle - headAngle)) * + headLen; + final p2 = + end - + Offset(math.cos(angle + headAngle), math.sin(angle + headAngle)) * + headLen; + canvas.drawLine(end, p1, paint); + canvas.drawLine(end, p2, paint); + } + + void _paintText(Canvas canvas, TextMark t) { + final tp = layoutText(t); + canvas.save(); + canvas.translate(t.position.dx, t.position.dy); + canvas.rotate(t.rotation); + tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2)); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant DrawingPainter oldDelegate) => true; +} + +final Expando<_TextLayout> _textLayoutCache = Expando<_TextLayout>(); + +class _TextLayout { + final String text; + final double fontSize; + final Color color; + final TextPainter painter; + + _TextLayout(this.text, this.fontSize, this.color, this.painter); +} + +TextPainter layoutText(TextMark t) { + final cached = _textLayoutCache[t]; + if (cached != null && + cached.text == t.text && + cached.fontSize == t.fontSize && + cached.color == t.color) { + return cached.painter; + } + final tp = TextPainter( + text: TextSpan( + text: t.text, + style: TextStyle( + color: t.color, + fontSize: t.fontSize, + fontWeight: FontWeight.w600, + shadows: const [Shadow(blurRadius: 4, color: Colors.black54)], + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(maxWidth: 2000); + _textLayoutCache[t] = _TextLayout(t.text, t.fontSize, t.color, tp); + return tp; +} + +Size textMarkSize(TextMark t) { + final tp = layoutText(t); + return Size(tp.width + 32, tp.height + 24); +} + +(Offset, Offset) handlePositions(TextMark t) { + final hw = textMarkSize(t).width / 2; + final c = math.cos(t.rotation); + final s = math.sin(t.rotation); + return ( + t.position + Offset(-hw * c, -hw * s), + t.position + Offset(hw * c, hw * s), + ); +} + +class SelectionPainter extends CustomPainter { + final TextMark text; + final Color accent; + + SelectionPainter(this.text, this.accent); + + @override + void paint(Canvas canvas, Size size) { + final box = textMarkSize(text); + final hw = box.width / 2; + final hh = box.height / 2; + canvas.save(); + canvas.translate(text.position.dx, text.position.dy); + canvas.rotate(text.rotation); + + final border = Paint() + ..color = Colors.white + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final tl = Offset(-hw, -hh); + final tr = Offset(hw, -hh); + final br = Offset(hw, hh); + final bl = Offset(-hw, hh); + _dashedLine(canvas, tl, tr, border); + _dashedLine(canvas, tr, br, border); + _dashedLine(canvas, br, bl, border); + _dashedLine(canvas, bl, tl, border); + + final fill = Paint() + ..color = accent + ..style = PaintingStyle.fill; + final ring = Paint() + ..color = Colors.white + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + for (final c in [Offset(-hw, 0), Offset(hw, 0)]) { + canvas.drawCircle(c, 7, fill); + canvas.drawCircle(c, 7, ring); + } + canvas.restore(); + } + + void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) { + const dash = 7.0; + const gap = 5.0; + final total = (b - a).distance; + if (total <= 0) return; + final dir = (b - a) / total; + var d = 0.0; + while (d < total) { + final start = a + dir * d; + final end = a + dir * math.min(d + dash, total); + canvas.drawLine(start, end, paint); + d += dash + gap; + } + } + + @override + bool shouldRepaint(covariant SelectionPainter oldDelegate) => true; +} + +class ColorPicker extends StatefulWidget { + final Color color; + final ValueChanged onChanged; + + const ColorPicker({super.key, required this.color, required this.onChanged}); + + @override + State createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State { + late HSVColor _hsv; + + @override + void initState() { + super.initState(); + final hsv = HSVColor.fromColor(widget.color); + _hsv = hsv.saturation == 0 ? hsv.withHue(0) : hsv; + } + + void _setSV(Offset pos, Size size) { + if (size.width <= 0 || size.height <= 0) return; + final s = (pos.dx / size.width).clamp(0.0, 1.0); + final v = (1 - pos.dy / size.height).clamp(0.0, 1.0); + setState(() => _hsv = _hsv.withSaturation(s).withValue(v)); + widget.onChanged(_hsv.toColor()); + } + + void _setHue(double dx, double width) { + if (width <= 0) return; + setState(() => _hsv = _hsv.withHue((dx / width).clamp(0.0, 1.0) * 360)); + widget.onChanged(_hsv.toColor()); + } + + @override + Widget build(BuildContext context) { + final hueColor = HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); + return Container( + color: kEditorDrawPanel, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 132, + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _setSV(d.localPosition, size), + onPanUpdate: (d) => _setSV(d.localPosition, size), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.white, hueColor], + ), + ), + ), + ), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black], + ), + ), + ), + ), + Positioned( + left: _hsv.saturation * size.width - 9, + top: (1 - _hsv.value) * size.height - 9, + child: _thumb(_hsv.toColor()), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 14), + SizedBox( + height: 22, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _setHue(d.localPosition.dx, width), + onPanUpdate: (d) => _setHue(d.localPosition.dx, width), + child: ClipRRect( + borderRadius: BorderRadius.circular(11), + child: Stack( + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ), + ), + ), + ), + Positioned( + left: (_hsv.hue / 360) * width - 9, + top: 1, + bottom: 1, + child: _thumb(hueColor), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _thumb(Color color) { + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)], + ), + ); + } +} + +class ColorAdjust { + double enhance; + double exposure; + double contrast; + double saturation; + double warmth; + double vignette; + + ColorAdjust({ + this.enhance = 0, + this.exposure = 0, + this.contrast = 0, + this.saturation = 0, + this.warmth = 0, + this.vignette = 0, + }); + + ColorAdjust copy() => ColorAdjust( + enhance: enhance, + exposure: exposure, + contrast: contrast, + saturation: saturation, + warmth: warmth, + vignette: vignette, + ); + + bool get pristine => + enhance == 0 && + exposure == 0 && + contrast == 0 && + saturation == 0 && + warmth == 0 && + vignette == 0; + + bool get colorPristine => + enhance == 0 && + exposure == 0 && + contrast == 0 && + saturation == 0 && + warmth == 0; + + List matrix() { + var m = identityMatrix(); + m = mulMatrix(brightnessMatrix(1 + exposure), m); + m = mulMatrix(contrastMatrix(1 + contrast), m); + m = mulMatrix(saturationMatrix(1 + saturation), m); + m = mulMatrix(warmthMatrix(warmth), m); + if (enhance > 0) { + m = mulMatrix(contrastMatrix(1 + enhance * 0.35), m); + m = mulMatrix(saturationMatrix(1 + enhance * 0.4), m); + m = mulMatrix(brightnessMatrix(1 + enhance * 0.05), m); + } + return m; + } + + Gradient vignetteGradient() => RadialGradient( + radius: 0.9, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: (vignette * 0.6).clamp(0.0, 1.0)), + ], + stops: const [0.5, 1.0], + ); +} + +class AdjustSliders extends StatelessWidget { + final ColorAdjust adjust; + final VoidCallback onChanged; + + const AdjustSliders({ + super.key, + required this.adjust, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _slider(context, l10n.photoEditorEnhance, adjust.enhance, 0, 1, (v) { + adjust.enhance = v; + }), + _slider(context, l10n.photoEditorExposure, adjust.exposure, -1, 1, ( + v, + ) { + adjust.exposure = v; + }), + _slider(context, l10n.photoEditorContrast, adjust.contrast, -1, 1, ( + v, + ) { + adjust.contrast = v; + }), + _slider( + context, + l10n.photoEditorSaturation, + adjust.saturation, + -1, + 1, + (v) { + adjust.saturation = v; + }, + ), + _slider(context, l10n.photoEditorWarmth, adjust.warmth, -1, 1, (v) { + adjust.warmth = v; + }), + _slider(context, l10n.photoEditorVignette, adjust.vignette, 0, 1, ( + v, + ) { + adjust.vignette = v; + }), + ], + ), + ); + } + + Widget _slider( + BuildContext context, + String label, + double value, + double min, + double max, + ValueChanged apply, + ) { + return Row( + children: [ + SizedBox( + width: 104, + child: Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbColor: Colors.white, + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), + ), + child: Slider( + min: min, + max: max, + value: value.clamp(min, max), + onChanged: (v) { + apply(v); + onChanged(); + }, + ), + ), + ), + ], + ); + } +} + +List identityMatrix() => [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]; + +List brightnessMatrix(double f) => [ + f, + 0, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]; + +List contrastMatrix(double c) { + final t = 127.5 * (1 - c); + return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0]; +} + +List saturationMatrix(double s) { + const lr = 0.2126; + const lg = 0.7152; + const lb = 0.0722; + final i = 1 - s; + return [ + lr * i + s, + lg * i, + lb * i, + 0, + 0, + lr * i, + lg * i + s, + lb * i, + 0, + 0, + lr * i, + lg * i, + lb * i + s, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]; +} + +List warmthMatrix(double w) { + final o = w * 25.0; + return [1, 0, 0, 0, o, 0, 1, 0, 0, 0, 0, 0, 1, 0, -o, 0, 0, 0, 1, 0]; +} + +List mulMatrix(List a, List b) { + double at(List m, int r, int c) => + r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0); + final out = List.filled(20, 0); + for (var r = 0; r < 4; r++) { + for (var c = 0; c < 5; c++) { + var sum = 0.0; + for (var k = 0; k < 5; k++) { + sum += at(a, r, k) * at(b, k, c); + } + out[r * 5 + c] = sum; + } + } + return out; +} diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index cb7cb36..f67467b 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -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 { 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 { void _setWorkingFile(File file) { _workingFile = file; widget.hero.image.value = FileImage(file); + _resolveWorkingSize(file); + } + + Future _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 _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 { widget.onSend(); } - Future _pushEditor(Widget editor) { - return Navigator.of(context).push( - PageRouteBuilder( - opaque: true, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => editor, - ), + Future _pushEditor(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(PhotoHeroRoute(hero: hero, builder: (_) => builder())); + } finally { + _activeHero = null; + } } void _reportEdit() { @@ -129,17 +160,24 @@ class _MediaPreviewScreenState extends State { final source = _cropSource ??= widget.item.localFile ?? await widget.item.originFile(); if (source == null || !mounted) return; - final result = await _pushEditor( - PhotoCropEditor(source: source, initialState: _cropState), + await _pushEditor( + () => 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 _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 _openDraw() async { @@ -151,37 +189,36 @@ class _MediaPreviewScreenState extends State { showCustomNotification(context, 'Не удалось открыть редактор'); return; } - final result = await _pushEditor( - PhotoDrawEditor(source: file, imageWidth: dims.$1, imageHeight: dims.$2), + await _pushEditor( + () => 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 _openAdjust() async { final file = _workingFile; if (file == null) return; - final result = await _pushEditor(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( + () => PhotoAdjustEditor(source: file, onPreview: _applyBaked), + ); + } + + Future _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 { 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 { minScale: 1, maxScale: 4, transformationController: _zoom, - child: _buildImage(), + child: KeyedSubtree(key: _stageKey, child: _buildImage()), ), ), ), @@ -269,7 +306,7 @@ class _MediaPreviewScreenState extends State { 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 { 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 { 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> 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>( - 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( - 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), - ), - ), - ); - } -} diff --git a/lib/frontend/widgets/attachment/photo_editor.dart b/lib/frontend/widgets/attachment/photo_editor.dart index dced092..b77a9ab 100644 --- a/lib/frontend/widgets/attachment/photo_editor.dart +++ b/lib/frontend/widgets/attachment/photo_editor.dart @@ -13,39 +13,10 @@ import 'package:komet/frontend/widgets/custom_notification.dart'; import '../../../core/config/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../small_spinner.dart'; -import '../../../core/config/app_shape.dart'; +import 'editor_common.dart'; +import 'photo_hero.dart'; -const Color _kPanel = Color(0xFF0A0A0A); - -const List _kPenWheel = [ - Color(0xFFFF3B30), - Color(0xFFFFCC00), - Color(0xFF34C759), - Color(0xFF00C7BE), - Color(0xFF2F8FFF), - Color(0xFFAF52DE), - Color(0xFFFF3B30), -]; - -class CropState { - final int quarterTurns; - final bool flipH; - final double straightenDeg; - final Rect cropNorm; - - const CropState({ - required this.quarterTurns, - required this.flipH, - required this.straightenDeg, - required this.cropNorm, - }); - - bool sameAs(CropState o) => - quarterTurns == o.quarterTurns && - flipH == o.flipH && - (straightenDeg - o.straightenDeg).abs() < 0.05 && - cropNorm == o.cropNorm; -} +export 'editor_common.dart' show CropState; class CropResult { final File file; @@ -65,8 +36,14 @@ class PhotoEditState { class PhotoCropEditor extends StatefulWidget { final File source; final CropState? initialState; + final Future Function(CropResult result)? onPreview; - const PhotoCropEditor({super.key, required this.source, this.initialState}); + const PhotoCropEditor({ + super.key, + required this.source, + this.initialState, + this.onPreview, + }); @override State createState() => _PhotoCropEditorState(); @@ -74,15 +51,6 @@ class PhotoCropEditor extends StatefulWidget { class _PhotoCropEditorState extends State { ui.Image? _image; - int _quarterTurns = 0; - bool _flipH = false; - double _straightenDeg = 0; - Rect? _crop; - Size _viewport = Size.zero; - bool _baking = false; - bool _stateApplied = false; - int _handle = -1; - final ValueNotifier _rev = ValueNotifier(0); @override void initState() { @@ -90,11 +58,6 @@ class _PhotoCropEditorState extends State { _load(); } - void _setCrop(Rect r) { - _crop = r; - _rev.value++; - } - Future _load() async { try { final bytes = await widget.source.readAsBytes(); @@ -114,1761 +77,149 @@ class _PhotoCropEditorState extends State { @override void dispose() { _image?.dispose(); - _rev.dispose(); super.dispose(); } - double get _imgW => _image!.width.toDouble(); - double get _imgH => _image!.height.toDouble(); - double get _phi => - _straightenDeg * math.pi / 180 - _quarterTurns * math.pi / 2; - - Size _orientedSize() { - final swap = _quarterTurns.isOdd; - return swap ? Size(_imgH, _imgW) : Size(_imgW, _imgH); - } - - double _baseScale(Size vp) { - final o = _orientedSize(); - const margin = 0.9; - return math.min(vp.width / o.width, vp.height / o.height) * margin; - } - - Rect _fittedRect(Size vp) { - final o = _orientedSize(); - final base = _baseScale(vp); - return Rect.fromCenter( - center: Offset(vp.width / 2, vp.height / 2), - width: o.width * base, - height: o.height * base, - ); - } - - double _scaleFor(Size vp, Rect crop) { - final base = _baseScale(vp); - final center = Offset(vp.width / 2, vp.height / 2); - final c = math.cos(-_phi); - final s = math.sin(-_phi); - var maxS = 0.0; - for (final corner in [ - crop.topLeft, - crop.topRight, - crop.bottomLeft, - crop.bottomRight, - ]) { - final rx = corner.dx - center.dx; - final ry = corner.dy - center.dy; - final lx = rx * c - ry * s; - final ly = rx * s + ry * c; - maxS = math.max( - maxS, - math.max(lx.abs() / (_imgW / 2), ly.abs() / (_imgH / 2)), - ); - } - return math.max(base, maxS); - } - - Matrix4 _matrix(Size vp, Rect crop) { - final scale = _scaleFor(vp, crop); - return Matrix4.identity() - ..translateByDouble(vp.width / 2, vp.height / 2, 0, 1) - ..multiply( - _flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity(), - ) - ..rotateZ(_phi) - ..scaleByDouble(scale, scale, 1, 1) - ..translateByDouble(-_imgW / 2, -_imgH / 2, 0, 1); - } - - void _ensureCrop(Size vp) { - if (_crop != null && _viewport == vp) return; - _viewport = vp; - final init = widget.initialState; - if (init != null && !_stateApplied) { - _stateApplied = true; - _quarterTurns = init.quarterTurns; - _flipH = init.flipH; - _straightenDeg = init.straightenDeg; - _crop = Rect.fromLTRB( - init.cropNorm.left * vp.width, - init.cropNorm.top * vp.height, - init.cropNorm.right * vp.width, - init.cropNorm.bottom * vp.height, - ); - } else { - _crop = _fittedRect(vp); - } - } - - CropState _currentState(Size vp, Rect crop) => CropState( - quarterTurns: _quarterTurns, - flipH: _flipH, - straightenDeg: _straightenDeg, - cropNorm: Rect.fromLTRB( - crop.left / vp.width, - crop.top / vp.height, - crop.right / vp.width, - crop.bottom / vp.height, - ), - ); - - void _reset() { - setState(() { - _quarterTurns = 0; - _flipH = false; - _straightenDeg = 0; - _crop = _fittedRect(_viewport); - }); - } - - void _rotate90() { - setState(() { - _quarterTurns = (_quarterTurns + 1) % 4; - _straightenDeg = 0; - _crop = _fittedRect(_viewport); - }); - } - - void _flip() => setState(() => _flipH = !_flipH); - - int _hitHandle(Offset pt, Rect c) { - const r = 34.0; - final corners = [c.topLeft, c.topRight, c.bottomRight, c.bottomLeft]; - for (var i = 0; i < 4; i++) { - if ((pt - corners[i]).distance < r) return i; - } - final insideV = pt.dy > c.top - r && pt.dy < c.bottom + r; - final insideH = pt.dx > c.left - r && pt.dx < c.right + r; - if ((pt.dx - c.left).abs() < r && insideV) return 4; - if ((pt.dx - c.right).abs() < r && insideV) return 5; - if ((pt.dy - c.top).abs() < r && insideH) return 6; - if ((pt.dy - c.bottom).abs() < r && insideH) return 7; - if (c.contains(pt)) return 8; - return -1; - } - - void _onPanStart(Offset pt) { - final c = _crop; - if (c == null) return; - _handle = _hitHandle(pt, c); - } - - void _onPanUpdate(Offset delta) { - final c = _crop; - if (c == null || _handle < 0) return; - final b = _fittedRect(_viewport); - const minSize = 64.0; - - if (_handle == 8) { - var nl = c.left + delta.dx; - var nt = c.top + delta.dy; - var nr = c.right + delta.dx; - var nb = c.bottom + delta.dy; - if (nl < b.left) { - nr += b.left - nl; - nl = b.left; - } - if (nt < b.top) { - nb += b.top - nt; - nt = b.top; - } - if (nr > b.right) { - nl -= nr - b.right; - nr = b.right; - } - if (nb > b.bottom) { - nt -= nb - b.bottom; - nb = b.bottom; - } - _setCrop(Rect.fromLTRB(nl, nt, nr, nb)); - return; - } - - var l = c.left; - var t = c.top; - var r = c.right; - var bo = c.bottom; - switch (_handle) { - case 0: - l += delta.dx; - t += delta.dy; - case 1: - r += delta.dx; - t += delta.dy; - case 2: - r += delta.dx; - bo += delta.dy; - case 3: - l += delta.dx; - bo += delta.dy; - case 4: - l += delta.dx; - case 5: - r += delta.dx; - case 6: - t += delta.dy; - case 7: - bo += delta.dy; - } - l = l.clamp(b.left, math.max(b.left, r - minSize)); - t = t.clamp(b.top, math.max(b.top, bo - minSize)); - r = r.clamp(math.min(b.right, l + minSize), b.right); - bo = bo.clamp(math.min(b.bottom, t + minSize), b.bottom); - _setCrop(Rect.fromLTRB(l, t, r, bo)); - } - - Future _done() async { - if (_baking) return; - final crop = _crop; - final vp = _viewport; - if (crop == null || vp == Size.zero) { - Navigator.of(context).pop(); - return; - } - final state = _currentState(vp, crop); - final init = widget.initialState; - final noChange = init != null - ? state.sameAs(init) - : (_quarterTurns == 0 && - !_flipH && - _straightenDeg == 0 && - _isFullCrop()); - if (noChange) { - Navigator.of(context).pop(); - return; - } - setState(() => _baking = true); - final file = await _bake(); - if (!mounted) return; - if (file == null) { - setState(() => _baking = false); - showCustomNotification( - context, - AppLocalizations.of(context)!.photoEditorApplyFailed, - ); - return; - } - Navigator.of(context).pop(CropResult(file, state)); - } - - bool _isFullCrop() { - final c = _crop; - if (c == null) return true; - final f = _fittedRect(_viewport); - return (c.left - f.left).abs() < 1 && - (c.top - f.top).abs() < 1 && - (c.right - f.right).abs() < 1 && - (c.bottom - f.bottom).abs() < 1; - } - - Future _bake() async { - final img = _image; - final crop = _crop; - final vp = _viewport; - if (img == null || crop == null || vp == Size.zero) return null; - try { - final m = _matrix(vp, crop); - final upscale = 1 / _baseScale(vp); - var outW = crop.width * upscale; - var outH = crop.height * upscale; - const maxDim = 4096; - final mx = math.max(outW, outH); - final cap = mx > maxDim ? maxDim / mx : 1.0; - final eff = upscale * cap; - final pxW = (crop.width * eff).round(); - final pxH = (crop.height * eff).round(); - if (pxW <= 0 || pxH <= 0) return null; - - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - canvas.scale(eff); - canvas.translate(-crop.left, -crop.top); - canvas.transform(m.storage); - canvas.drawImage( - img, - Offset.zero, - Paint()..filterQuality = FilterQuality.high, - ); - final picture = recorder.endRecording(); - return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop'); - } catch (_) { - return null; - } + Future _apply( + CropState state, + Size vp, + bool changed, + bool identity, + ) async { + if (!changed) return null; + final file = await _bakeCrop(_image!, state, vp); + if (file == null) return null; + final result = CropResult(file, state); + await widget.onPreview?.call(result); + return result; } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Column( - children: [ - Expanded(child: _buildViewport()), - _buildTools(), - _buildActions(), - ], - ), - ), - ); - } - - Widget _buildViewport() { - final img = _image; - if (img == null) { - return const Center(child: SmallSpinner(size: 36, color: Colors.white)); + final image = _image; + if (image == null) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center(child: SmallSpinner(size: 36, color: Colors.white)), + ); } - return LayoutBuilder( - builder: (context, constraints) { - final vp = constraints.biggest; - _ensureCrop(vp); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (d) => _onPanStart(d.localPosition), - onPanUpdate: (d) => _onPanUpdate(d.delta), - child: ValueListenableBuilder( - valueListenable: _rev, - builder: (context, _, _) { - final crop = _crop!; - return CustomPaint( - size: vp, - painter: _CropPainter( - image: img, - matrix: _matrix(vp, crop), - crop: crop, - ), - ); - }, - ), - ); - }, - ); - } - - Widget _buildTools() { - final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - IconButton( - onPressed: _flip, - icon: Icon( - Symbols.flip, - color: _flipH ? MediaAccent.of(context) : Colors.white, - ), - tooltip: l10n.photoEditorFlipTooltip, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _rev, - builder: (context, _, _) => _StraightenRuler( - value: _straightenDeg, - onChanged: (v) { - _straightenDeg = v; - _rev.value++; - }, - ), - ), - ), - IconButton( - onPressed: _rotate90, - icon: const Icon( - Symbols.rotate_90_degrees_ccw, - color: Colors.white, - ), - tooltip: l10n.photoEditorRotateTooltip, - ), - ], - ), - ); - } - - Widget _buildActions() { - final l10n = AppLocalizations.of(context)!; - return Container( - color: _kPanel, - padding: const EdgeInsets.symmetric(vertical: 6), - child: 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: _reset, - child: Text( - l10n.photoEditorReset, - style: const TextStyle(color: Colors.white, fontSize: 15), - ), - ), - TextButton( - onPressed: _baking ? null : _done, - child: Text( - l10n.photoEditorDone, - style: TextStyle( - color: _baking ? Colors.white38 : MediaAccent.of(context), - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), + return CropWorkspace( + imageSize: Size(image.width.toDouble(), image.height.toDouble()), + initialState: widget.initialState, + onApply: _apply, + imageBuilder: (context, matrix) => + CustomPaint(painter: MatrixImagePainter(image, matrix)), ); } } -class _CropPainter extends CustomPainter { - final ui.Image image; - final Matrix4 matrix; - final Rect crop; +Future _bakeCrop(ui.Image img, CropState state, Size vp) async { + if (vp == Size.zero) return null; + try { + final geometry = CropGeometry( + source: Size(img.width.toDouble(), img.height.toDouble()), + quarterTurns: state.quarterTurns, + flipH: state.flipH, + straightenDeg: state.straightenDeg, + ); + final crop = Rect.fromLTRB( + state.cropNorm.left * vp.width, + state.cropNorm.top * vp.height, + state.cropNorm.right * vp.width, + state.cropNorm.bottom * vp.height, + ); + final m = geometry.viewportMatrix(vp, crop); + final upscale = 1 / geometry.baseScale(vp); + const maxDim = 4096; + final mx = math.max(crop.width * upscale, crop.height * upscale); + final cap = mx > maxDim ? maxDim / mx : 1.0; + final eff = upscale * cap; + final pxW = (crop.width * eff).round(); + final pxH = (crop.height * eff).round(); + if (pxW <= 0 || pxH <= 0) return null; - _CropPainter({required this.image, required this.matrix, required this.crop}); - - @override - void paint(Canvas canvas, Size size) { - canvas.save(); - canvas.transform(matrix.storage); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(eff); + canvas.translate(-crop.left, -crop.top); + canvas.transform(m.storage); canvas.drawImage( - image, + img, Offset.zero, - Paint()..filterQuality = FilterQuality.medium, - ); - canvas.restore(); - - canvas.drawPath( - Path.combine( - PathOperation.difference, - Path()..addRect(Offset.zero & size), - Path()..addRect(crop), - ), - Paint()..color = Colors.black.withValues(alpha: 0.55), - ); - - final grid = Paint() - ..color = Colors.white.withValues(alpha: 0.4) - ..strokeWidth = 0.7; - for (var i = 1; i < 3; i++) { - final x = crop.left + crop.width * i / 3; - final y = crop.top + crop.height * i / 3; - canvas.drawLine(Offset(x, crop.top), Offset(x, crop.bottom), grid); - canvas.drawLine(Offset(crop.left, y), Offset(crop.right, y), grid); - } - - final border = Paint() - ..color = Colors.white.withValues(alpha: 0.7) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; - canvas.drawRect(crop, border); - - final bracket = Paint() - ..color = Colors.white - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - const len = 20.0; - void corner(Offset o, double dx, double dy) { - canvas.drawLine(o, o.translate(dx, 0), bracket); - canvas.drawLine(o, o.translate(0, dy), bracket); - } - - corner(crop.topLeft, len, len); - corner(crop.topRight, -len, len); - corner(crop.bottomLeft, len, -len); - corner(crop.bottomRight, -len, -len); - } - - @override - bool shouldRepaint(covariant _CropPainter old) => - old.matrix != matrix || old.crop != crop || old.image != image; -} - -class _StraightenRuler extends StatelessWidget { - final double value; - final ValueChanged onChanged; - - const _StraightenRuler({required this.value, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: (d) { - onChanged((value - d.delta.dx * 0.22).clamp(-45.0, 45.0)); - }, - onDoubleTap: () => onChanged(0), - child: SizedBox( - height: 56, - child: CustomPaint( - painter: _RulerPainter(value, MediaAccent.of(context)), - ), - ), + Paint()..filterQuality = FilterQuality.high, ); + final picture = recorder.endRecording(); + return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop'); + } catch (_) { + return null; } } -class _RulerPainter extends CustomPainter { - final double value; - final Color accent; - - _RulerPainter(this.value, this.accent); - - @override - void paint(Canvas canvas, Size size) { - final cx = size.width / 2; - const pxPerDeg = 6.0; - final baseY = size.height - 6; - - final tick = Paint()..strokeWidth = 1; - for (var deg = -60; deg <= 60; deg++) { - final x = cx + (deg - value) * pxPerDeg; - if (x < 0 || x > size.width) continue; - final major = deg % 5 == 0; - tick.color = Colors.white.withValues(alpha: major ? 0.85 : 0.4); - final h = major ? 14.0 : 8.0; - canvas.drawLine(Offset(x, baseY - h), Offset(x, baseY), tick); - } - - final tp = TextPainter( - text: TextSpan( - text: '${value.toStringAsFixed(1).replaceAll('.', ',')}°', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontStyle: FontStyle.italic, - ), - ), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, Offset(cx - tp.width / 2, 0)); - - canvas.drawLine( - Offset(cx, baseY - 18), - Offset(cx, baseY + 2), - Paint() - ..color = accent - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round, - ); - } - - @override - bool shouldRepaint(covariant _RulerPainter old) => - old.value != value || old.accent != accent; -} - -const Color _kDrawPanel = Color(0xFF101010); - -enum DrawTool { pen, marker, neon, eraser } - -enum ShapeKind { circle, rectangle, star, cloud, arrow } - -enum _EditTab { draw, stickers, text } - -sealed class EditMark {} - -class StrokeMark extends EditMark { - final List points; - final Color color; - final double width; - final DrawTool tool; - - StrokeMark({ - required this.points, - required this.color, - required this.width, - required this.tool, - }); -} - -class ShapeMark extends EditMark { - final ShapeKind kind; - final Offset start; - final Offset end; - final Color color; - final double width; - - ShapeMark({ - required this.kind, - required this.start, - required this.end, - required this.color, - required this.width, - }); -} - -class TextMark extends EditMark { - String text; - Offset position; - Color color; - double fontSize; - double rotation; - - TextMark({ - required this.text, - required this.position, - required this.color, - required this.fontSize, - this.rotation = 0, - }); -} - -class PhotoDrawEditor extends StatefulWidget { +class PhotoDrawEditor extends StatelessWidget { final File source; final int imageWidth; final int imageHeight; + final Future Function(File result)? onPreview; const PhotoDrawEditor({ super.key, required this.source, required this.imageWidth, required this.imageHeight, + this.onPreview, }); + Future _apply(List marks, Size canvas) async { + if (marks.isEmpty) return null; + final file = await _bakeMarks(source, marks, canvas); + if (file == null) return null; + await onPreview?.call(file); + return file; + } + @override - State createState() => _PhotoDrawEditorState(); + Widget build(BuildContext context) { + return MarkupEditor( + aspectRatio: imageHeight > 0 ? imageWidth / imageHeight : 1.0, + background: Image.file(source, fit: BoxFit.cover, gaplessPlayback: true), + onApply: _apply, + ); + } } -class _PhotoDrawEditorState extends State { - final GlobalKey _boundaryKey = GlobalKey(); - final ValueNotifier _canvasRev = ValueNotifier(0); - final List _marks = []; - StrokeMark? _liveStroke; - ShapeMark? _liveShape; - TextMark? _draggingText; +Future _bakeMarks(File source, List marks, Size box) async { + if (box.isEmpty) return null; + try { + final bytes = await source.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; - DrawTool _tool = DrawTool.pen; - Color _color = Colors.white; - double _width = 8; - TextMark? _selectedText; - bool _resizingText = false; - double _resizeBaseSize = 0; - double _resizeBaseDist = 1; - double _resizeBaseRotation = 0; - double _resizeBaseAngle = 0; - ShapeKind? _shapeMode; - _EditTab _tab = _EditTab.draw; - bool _paletteOpen = false; - bool _shapesOpen = false; - bool _baking = false; + const maxDim = 4096; + final srcMax = math.max(image.width, image.height); + final cap = srcMax > maxDim ? maxDim / srcMax : 1.0; + final outW = (image.width * cap).round(); + final outH = (image.height * cap).round(); + final scale = outW / box.width; - @override - void dispose() { - _canvasRev.dispose(); - super.dispose(); - } + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(scale); + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + Rect.fromLTWH(0, 0, box.width, box.height), + Paint(), + ); + DrawingPainter(marks: marks).paintMarks(canvas, box); + final picture = recorder.endRecording(); + image.dispose(); + codec.dispose(); - void _bumpCanvas() => _canvasRev.value++; - - void _undo() { - if (_marks.isEmpty) return; - if (identical(_marks.last, _selectedText)) _selectedText = null; - setState(() => _marks.removeLast()); - } - - void _clearAll() { - if (_marks.isEmpty) return; - _selectedText = null; - setState(_marks.clear); - } - - void _onPanStart(Offset pos) { - if (_tab == _EditTab.text) { - final sel = _selectedText; - if (sel != null && _nearHandle(sel, pos)) { - final v = pos - sel.position; - _resizingText = true; - _resizeBaseSize = sel.fontSize; - _resizeBaseDist = math.max(8, v.distance); - _resizeBaseRotation = sel.rotation; - _resizeBaseAngle = math.atan2(v.dy, v.dx); - return; - } - final hit = _hitText(pos); - _draggingText = hit; - if (hit != null && !identical(hit, _selectedText)) { - _selectedText = hit; - _bumpCanvas(); - } - return; - } - final shape = _shapeMode; - if (shape != null) { - _liveShape = ShapeMark( - kind: shape, - start: pos, - end: pos, - color: _color, - width: _width, - ); - } else { - _liveStroke = StrokeMark( - points: [pos], - color: _color, - width: _width, - tool: _tool, - ); - } - _bumpCanvas(); - } - - void _onPanUpdate(Offset pos) { - if (_tab == _EditTab.text) { - if (_resizingText) { - final sel = _selectedText; - if (sel != null) { - final v = pos - sel.position; - final angle = math.atan2(v.dy, v.dx); - sel.fontSize = (_resizeBaseSize * v.distance / _resizeBaseDist).clamp( - 10.0, - 200.0, - ); - sel.rotation = _resizeBaseRotation + (angle - _resizeBaseAngle); - _bumpCanvas(); - } - return; - } - final t = _draggingText; - if (t != null) { - t.position = pos; - _bumpCanvas(); - } - return; - } - final shape = _liveShape; - if (shape != null) { - _liveShape = ShapeMark( - kind: shape.kind, - start: shape.start, - end: pos, - color: shape.color, - width: shape.width, - ); - _bumpCanvas(); - } else if (_liveStroke != null) { - final pts = _liveStroke!.points; - if (pts.isEmpty || (pos - pts.last).distance >= 2.0) { - pts.add(pos); - _bumpCanvas(); - } - } - } - - void _onPanEnd() { - if (_tab == _EditTab.text) { - _resizingText = false; - _draggingText = null; - return; - } - final shape = _liveShape; - if (shape != null) { - if ((shape.end - shape.start).distance > 4) _marks.add(shape); - setState(() { - _liveShape = null; - _shapeMode = null; - }); - } else if (_liveStroke != null) { - if (_liveStroke!.points.isNotEmpty) _marks.add(_liveStroke!); - setState(() => _liveStroke = null); - } - } - - TextMark? _hitText(Offset pos) { - for (final m in _marks.reversed) { - if (m is! TextMark) continue; - final local = _toLocal(pos, m); - final box = textMarkSize(m); - if (local.dx.abs() <= box.width / 2 && local.dy.abs() <= box.height / 2) { - return m; - } - } + return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit'); + } catch (_) { return null; } - - Offset _toLocal(Offset pos, TextMark t) { - final v = pos - t.position; - final c = math.cos(-t.rotation); - final s = math.sin(-t.rotation); - return Offset(v.dx * c - v.dy * s, v.dx * s + v.dy * c); - } - - bool _nearHandle(TextMark t, Offset pos) { - final (left, right) = handlePositions(t); - return (pos - left).distance < 26 || (pos - right).distance < 26; - } - - Future _addText() async { - final l10n = AppLocalizations.of(context)!; - final controller = TextEditingController(); - final String? text; - try { - text = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), - shape: AppShape.dialogBorder, - title: Text( - l10n.photoEditorTextDialogTitle, - style: const TextStyle(color: Colors.white), - ), - content: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(color: Colors.white), - cursorColor: Colors.white, - decoration: InputDecoration( - hintText: l10n.photoEditorTextDialogHint, - hintStyle: const TextStyle(color: Colors.white38), - ), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(l10n.spoofDialogCancel), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: Text(l10n.photoEditorOk), - ), - ], - ), - ); - } finally { - controller.dispose(); - } - if (text == null || text.trim().isEmpty || !mounted) return; - final ro = _boundaryKey.currentContext?.findRenderObject(); - final size = ro is RenderBox ? ro.size : const Size(300, 300); - final mark = TextMark( - text: text.trim(), - position: Offset(size.width / 2, size.height / 2), - color: _color, - fontSize: 34, - ); - setState(() { - _marks.add(mark); - _selectedText = mark; - }); - } - - Future _apply() async { - if (_baking) return; - if (_marks.isEmpty) { - Navigator.of(context).pop(); - return; - } - setState(() => _baking = true); - final file = await _bake(); - if (!mounted) return; - if (file == null) { - setState(() => _baking = false); - showCustomNotification( - context, - AppLocalizations.of(context)!.photoEditorApplyChangesFailed, - ); - return; - } - Navigator.of(context).pop(file); - } - - Future _bake() async { - final ro = _boundaryKey.currentContext?.findRenderObject(); - if (ro is! RenderBox || ro.size.isEmpty) return null; - final box = ro.size; - try { - final bytes = await widget.source.readAsBytes(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - final image = frame.image; - - const maxDim = 4096; - final srcMax = math.max(image.width, image.height); - final cap = srcMax > maxDim ? maxDim / srcMax : 1.0; - final outW = (image.width * cap).round(); - final outH = (image.height * cap).round(); - final scale = outW / box.width; - - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - canvas.scale(scale); - canvas.drawImageRect( - image, - Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), - Rect.fromLTWH(0, 0, box.width, box.height), - Paint(), - ); - _DrawingPainter(marks: _marks).paintMarks(canvas, box); - final picture = recorder.endRecording(); - image.dispose(); - codec.dispose(); - - return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit'); - } catch (_) { - return null; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - Column( - children: [ - _buildTopBar(), - Expanded(child: _buildCanvas()), - _buildBottomPanel(), - ], - ), - if (_tab == _EditTab.draw) _buildSideSlider(), - if (_baking) const BusyOverlay(), - ], - ), - ); - } - - Widget _buildTopBar() { - return SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Row( - children: [ - IconButton( - onPressed: _marks.isEmpty ? null : _undo, - icon: const Icon(Symbols.undo), - color: Colors.white, - disabledColor: Colors.white24, - ), - const Spacer(), - TextButton( - onPressed: _marks.isEmpty ? null : _clearAll, - child: Text( - AppLocalizations.of(context)!.photoEditorClearAll, - style: TextStyle( - color: _marks.isEmpty ? Colors.white24 : Colors.white, - fontSize: 15, - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildCanvas() { - final aspect = widget.imageHeight > 0 - ? widget.imageWidth / widget.imageHeight - : 1.0; - return Center( - child: AspectRatio( - aspectRatio: aspect <= 0 ? 1.0 : aspect, - child: ValueListenableBuilder( - valueListenable: _canvasRev, - child: Image.file( - widget.source, - fit: BoxFit.cover, - gaplessPlayback: true, - ), - builder: (context, _, image) { - final selected = _tab == _EditTab.text ? _selectedText : null; - return Stack( - fit: StackFit.expand, - children: [ - RepaintBoundary( - key: _boundaryKey, - child: Stack( - fit: StackFit.expand, - children: [ - image!, - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (d) => _onPanStart(d.localPosition), - onPanUpdate: (d) => _onPanUpdate(d.localPosition), - onPanEnd: (_) => _onPanEnd(), - child: CustomPaint( - painter: _DrawingPainter( - marks: _marks, - live: _liveStroke ?? _liveShape, - ), - ), - ), - ), - ], - ), - ), - if (selected != null) - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: _SelectionPainter( - selected, - MediaAccent.of(context), - ), - ), - ), - ), - ], - ); - }, - ), - ), - ); - } - - Widget _buildSideSlider() { - return Positioned( - left: 2, - top: 0, - bottom: 0, - child: Center( - child: SizedBox( - height: 220, - child: RotatedBox( - quarterTurns: 3, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 3, - thumbColor: Colors.white, - activeTrackColor: Colors.white, - inactiveTrackColor: Colors.white24, - overlayShape: SliderComponentShape.noOverlay, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), - ), - child: Slider( - min: 2, - max: 40, - value: _width, - onChanged: (v) => setState(() => _width = v), - ), - ), - ), - ), - ), - ); - } - - Widget _buildBottomPanel() { - return Container( - color: _kDrawPanel, - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_paletteOpen) _buildColorPicker(), - if (_shapesOpen && _tab == _EditTab.draw) _buildShapesRow(), - _buildToolbar(), - const SizedBox(height: 2), - _buildTabs(), - ], - ), - ), - ); - } - - Widget _buildToolbar() { - switch (_tab) { - case _EditTab.draw: - return _buildDrawToolbar(); - case _EditTab.text: - return _buildTextToolbar(); - case _EditTab.stickers: - return const SizedBox(height: 56); - } - } - - Widget _buildDrawToolbar() { - return SizedBox( - height: 56, - child: Row( - children: [ - const SizedBox(width: 10), - _buildColorButton(), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildToolButton(DrawTool.pen, Symbols.edit), - _buildToolButton(DrawTool.marker, Symbols.ink_highlighter), - _buildToolButton(DrawTool.neon, Symbols.auto_awesome), - _buildToolButton(DrawTool.eraser, Symbols.ink_eraser), - ], - ), - ), - IconButton( - onPressed: () => setState(() { - _shapesOpen = !_shapesOpen; - _paletteOpen = false; - }), - icon: Icon( - Symbols.add, - color: _shapeMode != null ? _color : Colors.white, - ), - ), - const SizedBox(width: 8), - ], - ), - ); - } - - Widget _buildTextToolbar() { - return SizedBox( - height: 56, - child: Row( - children: [ - const SizedBox(width: 10), - _buildColorButton(), - const SizedBox(width: 14), - TextButton.icon( - onPressed: _addText, - icon: const Icon(Symbols.add, color: Colors.white), - label: Text( - AppLocalizations.of(context)!.photoEditorAddText, - style: const TextStyle(color: Colors.white, fontSize: 15), - ), - ), - const Spacer(), - ], - ), - ); - } - - Widget _buildColorButton() { - return GestureDetector( - onTap: () => setState(() { - _paletteOpen = !_paletteOpen; - _shapesOpen = false; - }), - child: Container( - width: 32, - height: 32, - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: SweepGradient(colors: _kPenWheel), - ), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _color, - border: Border.all(color: Colors.white, width: 1.5), - ), - ), - ), - ); - } - - Widget _buildToolButton(DrawTool tool, IconData icon) { - final selected = _shapeMode == null && _tool == tool; - return GestureDetector( - onTap: () => setState(() { - _tool = tool; - _shapeMode = null; - _shapesOpen = false; - }), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 3), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: selected - ? Colors.white.withValues(alpha: 0.18) - : Colors.transparent, - ), - child: Icon( - icon, - color: selected ? Colors.white : Colors.white60, - size: 24, - ), - ), - ); - } - - Widget _buildColorPicker() { - return _ColorPicker( - color: _color, - onChanged: (c) => setState(() { - _color = c; - if (_tab == _EditTab.text) _selectedText?.color = c; - }), - ); - } - - Widget _buildShapesRow() { - const shapes = <(ShapeKind, IconData)>[ - (ShapeKind.circle, Symbols.circle), - (ShapeKind.rectangle, Symbols.rectangle), - (ShapeKind.star, Symbols.star), - (ShapeKind.cloud, Symbols.cloud), - (ShapeKind.arrow, Symbols.north_east), - ]; - return SizedBox( - height: 48, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final (kind, icon) in shapes) - IconButton( - onPressed: () => setState(() { - _shapeMode = kind; - _shapesOpen = false; - }), - icon: Icon( - icon, - color: _shapeMode == kind ? _color : Colors.white, - ), - ), - ], - ), - ); - } - - Widget _buildTabs() { - final l10n = AppLocalizations.of(context)!; - return SizedBox( - height: 48, - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Symbols.close, color: Colors.white), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildTab(l10n.photoEditorTabDraw, _EditTab.draw), - _buildTab( - l10n.photoEditorTabStickers, - _EditTab.stickers, - disabled: true, - ), - _buildTab(l10n.photoEditorTabText, _EditTab.text), - ], - ), - ), - IconButton( - onPressed: _baking ? null : _apply, - icon: const Icon(Symbols.check, color: Colors.white), - ), - ], - ), - ); - } - - Widget _buildTab(String label, _EditTab tab, {bool disabled = false}) { - final selected = _tab == tab; - return GestureDetector( - onTap: disabled - ? null - : () => setState(() { - _tab = tab; - _paletteOpen = false; - _shapesOpen = false; - if (tab != _EditTab.draw) _shapeMode = null; - }), - child: Text( - label, - style: TextStyle( - color: disabled - ? Colors.white24 - : (selected ? Colors.white : Colors.white60), - fontSize: 14, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - letterSpacing: 0.5, - ), - ), - ); - } -} - -class _DrawingPainter extends CustomPainter { - final List marks; - final EditMark? live; - - _DrawingPainter({required this.marks, this.live}); - - @override - void paint(Canvas canvas, Size size) => paintMarks(canvas, size); - - void paintMarks(Canvas canvas, Size size) { - final needsLayer = _hasEraser(); - if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint()); - for (final m in marks) { - _paintMark(canvas, m); - } - final l = live; - if (l != null) _paintMark(canvas, l); - if (needsLayer) canvas.restore(); - } - - bool _hasEraser() { - for (final m in marks) { - if (m is StrokeMark && m.tool == DrawTool.eraser) return true; - } - final l = live; - return l is StrokeMark && l.tool == DrawTool.eraser; - } - - void _paintMark(Canvas canvas, EditMark m) { - switch (m) { - case StrokeMark s: - _paintStroke(canvas, s); - case ShapeMark sh: - _paintShape(canvas, sh); - case TextMark t: - _paintText(canvas, t); - } - } - - void _paintStroke(Canvas canvas, StrokeMark s) { - final paint = Paint() - ..color = s.color - ..strokeWidth = s.width - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..style = PaintingStyle.stroke; - - switch (s.tool) { - case DrawTool.pen: - break; - case DrawTool.marker: - paint.color = s.color.withValues(alpha: 0.4); - paint.strokeWidth = s.width * 1.6; - paint.strokeCap = StrokeCap.square; - case DrawTool.neon: - final glow = Paint() - ..color = s.color.withValues(alpha: 0.7) - ..strokeWidth = s.width * 2 - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..style = PaintingStyle.stroke - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); - _drawStrokeGeometry(canvas, s, glow); - paint.color = Colors.white; - case DrawTool.eraser: - paint.blendMode = BlendMode.clear; - } - - _drawStrokeGeometry(canvas, s, paint); - } - - void _drawStrokeGeometry(Canvas canvas, StrokeMark s, Paint paint) { - if (s.points.length < 2) { - final dot = Paint() - ..color = paint.color - ..blendMode = paint.blendMode - ..maskFilter = paint.maskFilter - ..style = PaintingStyle.fill; - canvas.drawCircle(s.points.first, paint.strokeWidth / 2, dot); - return; - } - final path = Path()..moveTo(s.points.first.dx, s.points.first.dy); - for (var i = 1; i < s.points.length; i++) { - path.lineTo(s.points[i].dx, s.points[i].dy); - } - canvas.drawPath(path, paint); - } - - void _paintShape(Canvas canvas, ShapeMark sh) { - final paint = Paint() - ..color = sh.color - ..strokeWidth = sh.width - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round; - final rect = Rect.fromPoints(sh.start, sh.end); - switch (sh.kind) { - case ShapeKind.circle: - canvas.drawOval(rect, paint); - case ShapeKind.rectangle: - canvas.drawRRect( - RRect.fromRectAndRadius(rect, const Radius.circular(10)), - paint, - ); - case ShapeKind.star: - canvas.drawPath(_starPath(rect), paint); - case ShapeKind.cloud: - canvas.drawPath(_cloudPath(rect), paint); - case ShapeKind.arrow: - _paintArrow(canvas, sh.start, sh.end, paint); - } - } - - Path _starPath(Rect rect) { - final cx = rect.center.dx; - final cy = rect.center.dy; - final outer = math.min(rect.width.abs(), rect.height.abs()) / 2; - final inner = outer * 0.45; - final path = Path(); - for (var i = 0; i < 10; i++) { - final r = i.isEven ? outer : inner; - final angle = -math.pi / 2 + i * math.pi / 5; - final x = cx + r * math.cos(angle); - final y = cy + r * math.sin(angle); - if (i == 0) { - path.moveTo(x, y); - } else { - path.lineTo(x, y); - } - } - path.close(); - return path; - } - - Path _cloudPath(Rect rect) { - final w = rect.width; - final h = rect.height; - Offset pt(double nx, double ny) => - Offset(rect.left + nx * w, rect.top + ny * h); - final path = Path()..moveTo(pt(0.25, 0.78).dx, pt(0.25, 0.78).dy); - path - ..cubicTo( - pt(0.0, 0.78).dx, - pt(0.0, 0.78).dy, - pt(0.0, 0.45).dx, - pt(0.0, 0.45).dy, - pt(0.22, 0.42).dx, - pt(0.22, 0.42).dy, - ) - ..cubicTo( - pt(0.2, 0.12).dx, - pt(0.2, 0.12).dy, - pt(0.56, 0.08).dx, - pt(0.56, 0.08).dy, - pt(0.62, 0.36).dx, - pt(0.62, 0.36).dy, - ) - ..cubicTo( - pt(0.86, 0.24).dx, - pt(0.86, 0.24).dy, - pt(1.02, 0.5).dx, - pt(1.02, 0.5).dy, - pt(0.8, 0.6).dx, - pt(0.8, 0.6).dy, - ) - ..cubicTo( - pt(1.02, 0.66).dx, - pt(1.02, 0.66).dy, - pt(0.96, 0.9).dx, - pt(0.96, 0.9).dy, - pt(0.74, 0.8).dx, - pt(0.74, 0.8).dy, - ) - ..cubicTo( - pt(0.7, 0.98).dx, - pt(0.7, 0.98).dy, - pt(0.34, 0.98).dx, - pt(0.34, 0.98).dy, - pt(0.25, 0.78).dx, - pt(0.25, 0.78).dy, - ) - ..close(); - return path; - } - - void _paintArrow(Canvas canvas, Offset start, Offset end, Paint paint) { - canvas.drawLine(start, end, paint); - final angle = math.atan2(end.dy - start.dy, end.dx - start.dx); - final headLen = math.max(paint.strokeWidth * 4, 18.0); - const headAngle = math.pi / 7; - final p1 = - end - - Offset(math.cos(angle - headAngle), math.sin(angle - headAngle)) * - headLen; - final p2 = - end - - Offset(math.cos(angle + headAngle), math.sin(angle + headAngle)) * - headLen; - canvas.drawLine(end, p1, paint); - canvas.drawLine(end, p2, paint); - } - - void _paintText(Canvas canvas, TextMark t) { - final tp = layoutText(t); - canvas.save(); - canvas.translate(t.position.dx, t.position.dy); - canvas.rotate(t.rotation); - tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2)); - canvas.restore(); - } - - @override - bool shouldRepaint(covariant _DrawingPainter oldDelegate) => true; -} - -final Expando<_TextLayout> _textLayoutCache = Expando<_TextLayout>(); - -class _TextLayout { - final String text; - final double fontSize; - final Color color; - final TextPainter painter; - - _TextLayout(this.text, this.fontSize, this.color, this.painter); -} - -TextPainter layoutText(TextMark t) { - final cached = _textLayoutCache[t]; - if (cached != null && - cached.text == t.text && - cached.fontSize == t.fontSize && - cached.color == t.color) { - return cached.painter; - } - final tp = TextPainter( - text: TextSpan( - text: t.text, - style: TextStyle( - color: t.color, - fontSize: t.fontSize, - fontWeight: FontWeight.w600, - shadows: const [Shadow(blurRadius: 4, color: Colors.black54)], - ), - ), - textAlign: TextAlign.center, - textDirection: TextDirection.ltr, - )..layout(maxWidth: 2000); - _textLayoutCache[t] = _TextLayout(t.text, t.fontSize, t.color, tp); - return tp; -} - -Size textMarkSize(TextMark t) { - final tp = layoutText(t); - return Size(tp.width + 32, tp.height + 24); -} - -(Offset, Offset) handlePositions(TextMark t) { - final hw = textMarkSize(t).width / 2; - final c = math.cos(t.rotation); - final s = math.sin(t.rotation); - return ( - t.position + Offset(-hw * c, -hw * s), - t.position + Offset(hw * c, hw * s), - ); -} - -class _SelectionPainter extends CustomPainter { - final TextMark text; - final Color accent; - - _SelectionPainter(this.text, this.accent); - - @override - void paint(Canvas canvas, Size size) { - final box = textMarkSize(text); - final hw = box.width / 2; - final hh = box.height / 2; - canvas.save(); - canvas.translate(text.position.dx, text.position.dy); - canvas.rotate(text.rotation); - - final border = Paint() - ..color = Colors.white - ..strokeWidth = 1.5 - ..style = PaintingStyle.stroke; - final tl = Offset(-hw, -hh); - final tr = Offset(hw, -hh); - final br = Offset(hw, hh); - final bl = Offset(-hw, hh); - _dashedLine(canvas, tl, tr, border); - _dashedLine(canvas, tr, br, border); - _dashedLine(canvas, br, bl, border); - _dashedLine(canvas, bl, tl, border); - - final fill = Paint() - ..color = accent - ..style = PaintingStyle.fill; - final ring = Paint() - ..color = Colors.white - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - for (final c in [Offset(-hw, 0), Offset(hw, 0)]) { - canvas.drawCircle(c, 7, fill); - canvas.drawCircle(c, 7, ring); - } - canvas.restore(); - } - - void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) { - const dash = 7.0; - const gap = 5.0; - final total = (b - a).distance; - if (total <= 0) return; - final dir = (b - a) / total; - var d = 0.0; - while (d < total) { - final start = a + dir * d; - final end = a + dir * math.min(d + dash, total); - canvas.drawLine(start, end, paint); - d += dash + gap; - } - } - - @override - bool shouldRepaint(covariant _SelectionPainter oldDelegate) => true; -} - -class _ColorPicker extends StatefulWidget { - final Color color; - final ValueChanged onChanged; - - const _ColorPicker({required this.color, required this.onChanged}); - - @override - State<_ColorPicker> createState() => _ColorPickerState(); -} - -class _ColorPickerState extends State<_ColorPicker> { - late HSVColor _hsv; - - @override - void initState() { - super.initState(); - final hsv = HSVColor.fromColor(widget.color); - _hsv = hsv.saturation == 0 ? hsv.withHue(0) : hsv; - } - - void _setSV(Offset pos, Size size) { - if (size.width <= 0 || size.height <= 0) return; - final s = (pos.dx / size.width).clamp(0.0, 1.0); - final v = (1 - pos.dy / size.height).clamp(0.0, 1.0); - setState(() => _hsv = _hsv.withSaturation(s).withValue(v)); - widget.onChanged(_hsv.toColor()); - } - - void _setHue(double dx, double width) { - if (width <= 0) return; - setState(() => _hsv = _hsv.withHue((dx / width).clamp(0.0, 1.0) * 360)); - widget.onChanged(_hsv.toColor()); - } - - @override - Widget build(BuildContext context) { - final hueColor = HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); - return Container( - color: _kDrawPanel, - padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 132, - child: LayoutBuilder( - builder: (context, constraints) { - final size = constraints.biggest; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (d) => _setSV(d.localPosition, size), - onPanUpdate: (d) => _setSV(d.localPosition, size), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Colors.white, hueColor], - ), - ), - ), - ), - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black], - ), - ), - ), - ), - Positioned( - left: _hsv.saturation * size.width - 9, - top: (1 - _hsv.value) * size.height - 9, - child: _thumb(_hsv.toColor()), - ), - ], - ), - ), - ); - }, - ), - ), - const SizedBox(height: 14), - SizedBox( - height: 22, - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (d) => _setHue(d.localPosition.dx, width), - onPanUpdate: (d) => _setHue(d.localPosition.dx, width), - child: ClipRRect( - borderRadius: BorderRadius.circular(11), - child: Stack( - children: [ - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFFFF0000), - Color(0xFFFFFF00), - Color(0xFF00FF00), - Color(0xFF00FFFF), - Color(0xFF0000FF), - Color(0xFFFF00FF), - Color(0xFFFF0000), - ], - ), - ), - ), - ), - Positioned( - left: (_hsv.hue / 360) * width - 9, - top: 1, - bottom: 1, - child: _thumb(hueColor), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ); - } - - Widget _thumb(Color color) { - return Container( - width: 18, - height: 18, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)], - ), - ); - } } enum BlurMode { off, radial, linear } @@ -1877,8 +228,9 @@ enum _Tab { adjust, blur, curves } class PhotoAdjustEditor extends StatefulWidget { final File source; + final Future Function(File result)? onPreview; - const PhotoAdjustEditor({super.key, required this.source}); + const PhotoAdjustEditor({super.key, required this.source, this.onPreview}); @override State createState() => _PhotoAdjustEditorState(); @@ -1888,12 +240,7 @@ class _PhotoAdjustEditorState extends State { ui.Image? _image; final ValueNotifier _rev = ValueNotifier(0); - double _enhance = 0; - double _exposure = 0; - double _contrast = 0; - double _saturation = 0; - double _warmth = 0; - double _vignette = 0; + final ColorAdjust _adjust = ColorAdjust(); BlurMode _blur = BlurMode.off; Offset _blurCenter = const Offset(0.5, 0.5); double _blurInner = 0.18; @@ -1972,28 +319,7 @@ class _PhotoAdjustEditorState extends State { bool get _curvesIdentity => _curves.every(_curveIdentity); bool get _pristine => - _enhance == 0 && - _exposure == 0 && - _contrast == 0 && - _saturation == 0 && - _warmth == 0 && - _vignette == 0 && - _blur == BlurMode.off && - _curvesIdentity; - - List _colorMatrix() { - var m = _identity(); - m = _mulMatrix(_brightness(1 + _exposure), m); - m = _mulMatrix(_contrastMatrix(1 + _contrast), m); - m = _mulMatrix(_saturationMatrix(1 + _saturation), m); - m = _mulMatrix(_warmthMatrix(_warmth), m); - if (_enhance > 0) { - m = _mulMatrix(_contrastMatrix(1 + _enhance * 0.35), m); - m = _mulMatrix(_saturationMatrix(1 + _enhance * 0.4), m); - m = _mulMatrix(_brightness(1 + _enhance * 0.05), m); - } - return m; - } + _adjust.pristine && _blur == BlurMode.off && _curvesIdentity; Gradient _maskGradient() { if (_blur == BlurMode.linear) { @@ -2235,15 +561,6 @@ class _PhotoAdjustEditorState extends State { } } - Gradient _vignetteGradient() => RadialGradient( - radius: 0.9, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: (_vignette * 0.6).clamp(0.0, 1.0)), - ], - stops: const [0.5, 1.0], - ); - Future _bake() async { final img = _image; if (img == null) return null; @@ -2268,7 +585,7 @@ class _PhotoAdjustEditorState extends State { canvas.saveLayer( rect, - Paint()..colorFilter = ColorFilter.matrix(_colorMatrix()), + Paint()..colorFilter = ColorFilter.matrix(_adjust.matrix()), ); if (_blur == BlurMode.off) { canvas.drawImageRect(curved, src, rect, Paint()); @@ -2293,10 +610,10 @@ class _PhotoAdjustEditorState extends State { } canvas.restore(); - if (_vignette > 0) { + if (_adjust.vignette > 0) { canvas.drawRect( rect, - Paint()..shader = _vignetteGradient().createShader(rect), + Paint()..shader = _adjust.vignetteGradient().createShader(rect), ); } @@ -2332,6 +649,8 @@ class _PhotoAdjustEditorState extends State { ); return; } + await widget.onPreview?.call(file); + if (!mounted) return; Navigator.of(context).pop(file); } @@ -2344,7 +663,11 @@ class _PhotoAdjustEditorState extends State { children: [ Column( children: [ - Expanded(child: ClipRect(child: _buildPreview())), + Expanded( + child: PhotoHeroTarget( + child: ClipRect(child: _buildPreview()), + ), + ), _buildTabContent(), _buildBottomBar(), ], @@ -2374,13 +697,15 @@ class _PhotoAdjustEditorState extends State { fit: StackFit.expand, children: [ ColorFiltered( - colorFilter: ColorFilter.matrix(_colorMatrix()), + colorFilter: ColorFilter.matrix(_adjust.matrix()), child: _buildBlurLayer(shown), ), - if (_vignette > 0) + if (_adjust.vignette > 0) IgnorePointer( child: DecoratedBox( - decoration: BoxDecoration(gradient: _vignetteGradient()), + decoration: BoxDecoration( + gradient: _adjust.vignetteGradient(), + ), ), ), if (blurTab) @@ -2543,101 +868,8 @@ class _PhotoAdjustEditorState extends State { Widget _buildSliders() { return ValueListenableBuilder( valueListenable: _rev, - builder: (context, _, _) { - final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _slider( - l10n.photoEditorEnhance, - _enhance, - 0, - 1, - (v) => _enhance = v, - ), - _slider( - l10n.photoEditorExposure, - _exposure, - -1, - 1, - (v) => _exposure = v, - ), - _slider( - l10n.photoEditorContrast, - _contrast, - -1, - 1, - (v) => _contrast = v, - ), - _slider( - l10n.photoEditorSaturation, - _saturation, - -1, - 1, - (v) => _saturation = v, - ), - _slider( - l10n.photoEditorWarmth, - _warmth, - -1, - 1, - (v) => _warmth = v, - ), - _slider( - l10n.photoEditorVignette, - _vignette, - 0, - 1, - (v) => _vignette = v, - ), - ], - ), - ); - }, - ); - } - - Widget _slider( - String label, - double value, - double min, - double max, - ValueChanged onChanged, - ) { - return Row( - children: [ - SizedBox( - width: 104, - child: Text( - label, - style: const TextStyle(color: Colors.white70, fontSize: 13), - overflow: TextOverflow.ellipsis, - ), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbColor: Colors.white, - activeTrackColor: Colors.white, - inactiveTrackColor: Colors.white24, - overlayShape: SliderComponentShape.noOverlay, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - ), - child: Slider( - min: min, - max: max, - value: value.clamp(min, max), - onChanged: (v) { - onChanged(v); - _rev.value++; - }, - ), - ), - ), - ], + builder: (context, _, _) => + AdjustSliders(adjust: _adjust, onChanged: () => _rev.value++), ); } @@ -2693,7 +925,7 @@ class _PhotoAdjustEditorState extends State { Widget _buildBottomBar() { final l10n = AppLocalizations.of(context)!; return Container( - color: _kPanel, + color: kEditorPanel, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ @@ -2738,91 +970,6 @@ class _PhotoAdjustEditorState extends State { } } -List _identity() => [ - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, -]; - -List _brightness(double f) => [ - f, - 0, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - 0, - 1, - 0, -]; - -List _contrastMatrix(double c) { - final t = 127.5 * (1 - c); - return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0]; -} - -List _saturationMatrix(double s) { - const lr = 0.2126; - const lg = 0.7152; - const lb = 0.0722; - final i = 1 - s; - return [ - lr * i + s, - lg * i, - lb * i, - 0, - 0, - lr * i, - lg * i + s, - lb * i, - 0, - 0, - lr * i, - lg * i, - lb * i + s, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]; -} - -List _warmthMatrix(double w) { - final o = w * 25.0; - return [1, 0, 0, 0, o, 0, 1, 0, 0, 0, 0, 0, 1, 0, -o, 0, 0, 0, 1, 0]; -} - Uint8List _applyLutsToBytes((Uint8List, List, List, List) args) { final (rgba, rl, gl, bl) = args; for (var i = 0; i < rgba.length; i += 4) { @@ -2833,22 +980,6 @@ Uint8List _applyLutsToBytes((Uint8List, List, List, List) args) { return rgba; } -List _mulMatrix(List a, List b) { - double at(List m, int r, int c) => - r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0); - final out = List.filled(20, 0); - for (var r = 0; r < 4; r++) { - for (var c = 0; c < 5; c++) { - var sum = 0.0; - for (var k = 0; k < 5; k++) { - sum += at(a, r, k) * at(b, k, c); - } - out[r * 5 + c] = sum; - } - } - return out; -} - class _RotateAround extends GradientTransform { final double radians; final Offset center; diff --git a/lib/frontend/widgets/attachment/photo_hero.dart b/lib/frontend/widgets/attachment/photo_hero.dart index 5e8733f..0cd2b0a 100644 --- a/lib/frontend/widgets/attachment/photo_hero.dart +++ b/lib/frontend/widgets/attachment/photo_hero.dart @@ -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 extends PageRouteBuilder { 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( - 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( + valueListenable: controller.flying, + child: child, + builder: (context, flying, child) => + flying ? Opacity(opacity: 0, child: child) : child!, ); } } +class RawImageProvider extends ImageProvider { + const RawImageProvider(this.image); + + final ui.Image image; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + RawImageProvider key, + ImageDecoderCallback decode, + ) => OneFrameImageStreamCompleter( + SynchronousFuture(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, diff --git a/lib/frontend/widgets/attachment/preview_chrome.dart b/lib/frontend/widgets/attachment/preview_chrome.dart new file mode 100644 index 0000000..497768c --- /dev/null +++ b/lib/frontend/widgets/attachment/preview_chrome.dart @@ -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> 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>( + 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 createState() => _FileToggleState(); +} + +class _FileToggleState extends State { + bool _active = false; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => setState(() => _active = !_active), + icon: TweenAnimationBuilder( + 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), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/video_edit.dart b/lib/frontend/widgets/attachment/video_edit.dart new file mode 100644 index 0000000..0d67add --- /dev/null +++ b/lib/frontend/widgets/attachment/video_edit.dart @@ -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 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 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? 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 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 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 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), + ); +} diff --git a/lib/frontend/widgets/attachment/video_editor.dart b/lib/frontend/widgets/attachment/video_editor.dart new file mode 100644 index 0000000..6be0e0a --- /dev/null +++ b/lib/frontend/widgets/attachment/video_editor.dart @@ -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 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 composeVideoStill( + ui.Image frame, + VideoGeometry geometry, + ColorAdjust adjust, { + List 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 Function(VideoCropResult result)? onPreview; + + const VideoCropEditor({ + super.key, + required this.frame, + required this.adjust, + this.initial, + this.onPreview, + }); + + Future _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 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 initialMarks; + final Future 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 marks; + final Size marksCanvas; + final Future 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 createState() => _VideoAdjustEditorState(); +} + +class _VideoAdjustEditorState extends State { + late final ColorAdjust _adjust = widget.initial.copy(); + final ValueNotifier _rev = ValueNotifier(0); + bool _busy = false; + + Future _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( + valueListenable: _rev, + builder: (context, _, _) => VideoStill( + frame: widget.frame, + geometry: widget.geometry, + adjust: _adjust, + marks: widget.marks, + marksCanvas: widget.marksCanvas, + ), + ), + ), + ), + ), + ), + ValueListenableBuilder( + 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 marks; + final Size marksCanvas; + final List options; + final int selected; + final double fps; + final Duration duration; + final String? title; + final Future 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 createState() => _VideoQualityEditorState(); +} + +class _VideoQualityEditorState extends State { + late int _index = math.max(0, widget.options.indexOf(widget.selected)); + bool _busy = false; + + Future _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 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'; + } +} diff --git a/lib/frontend/widgets/attachment/video_preview_screen.dart b/lib/frontend/widgets/attachment/video_preview_screen.dart new file mode 100644 index 0000000..38fd4c5 --- /dev/null +++ b/lib/frontend/widgets/attachment/video_preview_screen.dart @@ -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> selectedIds; + final VoidCallback onToggleSelection; + final VoidCallback onSend; + final VideoEditState edit; + final bool editable; + final VoidCallback? onEditChanged; + final String initialCaption; + final ValueChanged? 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 createState() => _VideoPreviewScreenState(); +} + +class _VideoPreviewScreenState extends State { + late final TextEditingController _caption = TextEditingController( + text: widget.initialCaption, + ); + + final GlobalKey _stageKey = GlobalKey(); + final List _flightImages = []; + + PhotoHeroController? _activeHero; + ui.Image? _editorFrame; + + File? _file; + VideoInfo? _info; + VideoPlayerController? _controller; + List _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 _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 _loadStrip(File file, Duration duration) async { + if (duration <= Duration.zero) return; + final step = duration.inMilliseconds / _kStripFrames; + final times = List.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 _pushEditor( + (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(PhotoHeroRoute(hero: hero, builder: (_) => builder())); + } finally { + _activeHero = null; + _editorFrame = null; + frame.dispose(); + _releaseFlights(); + } + } + + void _releaseFlights() { + if (_flightImages.isEmpty) return; + final images = List.of(_flightImages); + _flightImages.clear(); + for (final image in images) { + unawaited(RawImageProvider(image).evict()); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final image in images) { + image.dispose(); + } + }); + } + + Future _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 _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 _openCrop() async { + final prepared = await _prepare(withMarks: false); + if (prepared == null) return; + await _pushEditor( + prepared, + () => VideoCropEditor( + frame: prepared.$1, + adjust: _edit.adjust, + initial: _edit.crop, + onPreview: (result) => + _preview(() => _edit.crop = result.crop, withMarks: true), + ), + ); + } + + Future _openDraw() async { + final prepared = await _prepare(withMarks: true); + if (prepared == null) return; + await _pushEditor( + 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 _openAdjust() async { + final prepared = await _prepare(withMarks: true); + if (prepared == null) return; + await _pushEditor( + 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 _openQuality() async { + final prepared = await _prepare(withMarks: true); + if (prepared == null) return; + final natural = _naturalShortSide; + final options = videoQualityOptions(natural); + await _pushEditor( + 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>( + 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 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 createState() => _TrimBarState(); +} + +class _TrimBarState extends State { + 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( + 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), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ad76b1d..a8fff5e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 356274a..e2932f9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b617d4e..b3fc125 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 14827c9..5c405f4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Качество'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index b8ac6e9..2f7dff1 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -906,5 +906,13 @@ } }, "blacklistEmpty": "Никто не заблокирован", - "blacklistLoadError": "Не удалось загрузить чёрный список" + "blacklistLoadError": "Не удалось загрузить чёрный список", + "videoEditorQualityLow": "Небольшой размер", + "videoEditorQualityHigh": "Высокое качество", + "videoEditorCaptionHint": "Добавить подпись...", + "videoEditorMuteTooltip": "Отправить без звука", + "videoEditorProcessing": "Обработка видео…", + "videoEditorExportFailed": "Не удалось обработать видео", + "videoEditorFrameFailed": "Не удалось получить кадр", + "videoEditorQualityTooltip": "Качество" } diff --git a/test/crop_workspace_test.dart b/test/crop_workspace_test.dart new file mode 100644 index 0000000..6b06cf1 --- /dev/null +++ b/test/crop_workspace_test.dart @@ -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 _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 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)); + }); +} diff --git a/test/markup_editor_test.dart b/test/markup_editor_test.dart new file mode 100644 index 0000000..d27c2f7 --- /dev/null +++ b/test/markup_editor_test.dart @@ -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( + MaterialPageRoute( + 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); + }); +} diff --git a/test/photo_hero_test.dart b/test/photo_hero_test.dart index 68032d8..1dae2b4 100644 --- a/test/photo_hero_test.dart +++ b/test/photo_hero_test.dart @@ -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(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)); + }); } diff --git a/test/video_editor_test.dart b/test/video_editor_test.dart new file mode 100644 index 0000000..3965766 --- /dev/null +++ b/test/video_editor_test.dart @@ -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 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 _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))); + }); +} diff --git a/tool/make_morph_icons.py b/tool/make_morph_icons.py index 69e976b..f468c8f 100644 --- a/tool/make_morph_icons.py +++ b/tool/make_morph_icons.py @@ -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)], + ), ]