Files
Qlyra/ios/Runner/QlyraVideo.swift
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

334 lines
12 KiB
Swift

import AVFoundation
import CoreImage
import Flutter
import UIKit
private struct VideoExportSpec {
let input: String
let output: String
let startMs: Int?
let endMs: Int?
let removeAudio: Bool
let rotationDegrees: Double
let flipH: Bool
let crop: [Double]?
let outWidth: Int
let outHeight: Int
let rgbMatrix: [Double]?
let overlay: String?
let centerSquare: Bool
init?(_ arguments: Any?) {
guard let args = arguments as? [String: Any],
let input = args["input"] as? String,
let output = args["output"] as? String else { return nil }
self.input = input
self.output = output
startMs = (args["startMs"] as? NSNumber)?.intValue
endMs = (args["endMs"] as? NSNumber)?.intValue
removeAudio = (args["removeAudio"] as? NSNumber)?.boolValue ?? false
rotationDegrees = (args["rotationDegrees"] as? NSNumber)?.doubleValue ?? 0
flipH = (args["flipH"] as? NSNumber)?.boolValue ?? false
crop = (args["crop"] as? [NSNumber])?.map { $0.doubleValue }
outWidth = (args["outWidth"] as? NSNumber)?.intValue ?? 0
outHeight = (args["outHeight"] as? NSNumber)?.intValue ?? 0
rgbMatrix = (args["rgbMatrix"] as? [NSNumber])?.map { $0.doubleValue }
overlay = args["overlay"] as? String
centerSquare = false
}
init(input: String, output: String, edge: Int) {
self.input = input
self.output = output
startMs = nil
endMs = nil
removeAudio = false
rotationDegrees = 0
flipH = false
crop = nil
outWidth = edge
outHeight = edge
rgbMatrix = nil
overlay = nil
centerSquare = true
}
}
final class QlyraVideo {
static let shared = QlyraVideo()
private let queue = DispatchQueue(label: "ru.qlyra.app.video", qos: .userInitiated)
private var session: AVAssetExportSession?
private var cancelled = false
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "probe":
probe(call.arguments, result)
case "frames":
frames(call.arguments, result)
case "cropSquare":
cropSquare(call.arguments, result)
case "edit":
guard let spec = VideoExportSpec(call.arguments) else {
result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil))
return
}
export(spec) { ok in result(NSNumber(value: ok)) }
case "editProgress":
let value = session.map { Int(($0.progress * 100).rounded()) } ?? -1
result(NSNumber(value: value))
case "editCancel":
cancelled = true
session?.cancelExport()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func probe(_ arguments: Any?, _ result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any], let input = args["input"] as? String else {
result(FlutterError(code: "BAD_ARGS", message: "input required", details: nil))
return
}
queue.async {
let asset = AVURLAsset(url: URL(fileURLWithPath: input))
guard let track = asset.tracks(withMediaType: .video).first else {
Self.reply(result, nil)
return
}
let size = track.naturalSize.applying(track.preferredTransform)
let seconds = CMTimeGetSeconds(asset.duration)
let durationMs = seconds.isFinite && seconds > 0 ? Int((seconds * 1000).rounded()) : 0
let fps = Double(track.nominalFrameRate)
let payload: [String: Any] = [
"width": Int(abs(size.width).rounded()),
"height": Int(abs(size.height).rounded()),
"durationMs": durationMs,
"fps": fps > 0 ? fps : 30.0,
"hasAudio": !asset.tracks(withMediaType: .audio).isEmpty,
]
Self.reply(result, payload)
}
}
private func frames(_ arguments: Any?, _ result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any],
let input = args["input"] as? String,
let times = args["times"] as? [NSNumber] else {
result(FlutterError(code: "BAD_ARGS", message: "input/times required", details: nil))
return
}
let edge = (args["size"] as? NSNumber)?.intValue ?? 256
let precise = (args["precise"] as? NSNumber)?.boolValue ?? false
queue.async {
let asset = AVURLAsset(url: URL(fileURLWithPath: input))
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: edge, height: edge)
if precise {
generator.requestedTimeToleranceBefore = .zero
generator.requestedTimeToleranceAfter = .zero
}
var output: [Any] = []
for time in times {
let at = CMTime(value: CMTimeValue(time.int64Value), timescale: 1000)
guard let cgImage = try? generator.copyCGImage(at: at, actualTime: nil),
let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else {
output.append(NSNull())
continue
}
output.append(FlutterStandardTypedData(bytes: data))
}
Self.reply(result, output)
}
}
private func cropSquare(_ arguments: Any?, _ result: @escaping FlutterResult) {
guard let args = arguments as? [String: Any],
let input = args["input"] as? String,
let output = args["output"] as? String else {
result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil))
return
}
let edge = (args["size"] as? NSNumber)?.intValue ?? 480
export(VideoExportSpec(input: input, output: output, edge: edge)) { ok in
if ok {
result(output)
} else {
result(FlutterError(code: "TRANSCODE_FAILED", message: "export failed", details: nil))
}
}
}
private func export(_ spec: VideoExportSpec, completion: @escaping (Bool) -> Void) {
queue.async {
self.cancelled = false
let asset = AVURLAsset(url: URL(fileURLWithPath: spec.input))
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
Self.reply { completion(false) }
return
}
let totalSeconds = CMTimeGetSeconds(asset.duration)
let start = CMTime(value: CMTimeValue(spec.startMs ?? 0), timescale: 1000)
let endMs = spec.endMs ?? (totalSeconds.isFinite ? Int((totalSeconds * 1000).rounded()) : 0)
let end = CMTime(value: CMTimeValue(max(endMs, spec.startMs ?? 0)), timescale: 1000)
let range = CMTimeRange(start: start, end: end)
guard range.duration.seconds > 0 else {
Self.reply { completion(false) }
return
}
let composition = AVMutableComposition()
guard let compositionVideo = composition.addMutableTrack(
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
Self.reply { completion(false) }
return
}
do {
try compositionVideo.insertTimeRange(range, of: videoTrack, at: .zero)
} catch {
Self.reply { completion(false) }
return
}
compositionVideo.preferredTransform = videoTrack.preferredTransform
if !spec.removeAudio,
let audioTrack = asset.tracks(withMediaType: .audio).first,
let compositionAudio = composition.addMutableTrack(
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
try? compositionAudio.insertTimeRange(range, of: audioTrack, at: .zero)
}
let natural = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
let outWidth = spec.outWidth > 0 ? spec.outWidth : Int(abs(natural.width).rounded())
let outHeight = spec.outHeight > 0 ? spec.outHeight : Int(abs(natural.height).rounded())
guard outWidth > 0, outHeight > 0 else {
Self.reply { completion(false) }
return
}
let transform = videoTrack.preferredTransform
let overlay = spec.overlay.flatMap { UIImage(contentsOfFile: $0) }.flatMap { CIImage(image: $0) }
let renderSize = CGSize(width: outWidth, height: outHeight)
let videoComposition = AVMutableVideoComposition(asset: composition) { request in
let image = Self.render(
request.sourceImage,
spec: spec,
transform: transform,
overlay: overlay,
renderSize: renderSize)
request.finish(with: image, context: nil)
}
videoComposition.renderSize = renderSize
let outputURL = URL(fileURLWithPath: spec.output)
try? FileManager.default.removeItem(at: outputURL)
guard let session = AVAssetExportSession(
asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
Self.reply { completion(false) }
return
}
session.outputURL = outputURL
session.outputFileType = .mp4
session.videoComposition = videoComposition
session.shouldOptimizeForNetworkUse = true
self.session = session
session.exportAsynchronously {
let ok = session.status == .completed && !self.cancelled
self.session = nil
if !ok { try? FileManager.default.removeItem(at: outputURL) }
Self.reply { completion(ok) }
}
}
}
private static func render(
_ source: CIImage,
spec: VideoExportSpec,
transform: CGAffineTransform,
overlay: CIImage?,
renderSize: CGSize
) -> CIImage {
var image = normalized(source.transformed(by: transform))
if spec.flipH {
image = normalized(image.transformed(by: CGAffineTransform(scaleX: -1, y: 1)))
}
if abs(spec.rotationDegrees) > 0.01 {
let radians = CGFloat(spec.rotationDegrees * .pi / 180)
image = normalized(image.transformed(by: CGAffineTransform(rotationAngle: radians)))
}
if spec.centerSquare {
let extent = image.extent
let side = min(extent.width, extent.height)
image = normalized(image.cropped(to: CGRect(
x: extent.midX - side / 2,
y: extent.midY - side / 2,
width: side,
height: side)))
} else if let crop = spec.crop, crop.count == 4 {
let extent = image.extent
let left = CGFloat((crop[0] + 1) / 2)
let right = CGFloat((crop[1] + 1) / 2)
let bottom = CGFloat((1 - crop[2]) / 2)
let top = CGFloat((1 - crop[3]) / 2)
let rect = CGRect(
x: extent.minX + left * extent.width,
y: extent.minY + (1 - bottom) * extent.height,
width: max(1, (right - left) * extent.width),
height: max(1, (bottom - top) * extent.height))
image = normalized(image.cropped(to: rect))
}
let extent = image.extent
if extent.width > 0, extent.height > 0 {
image = image.transformed(by: CGAffineTransform(
scaleX: renderSize.width / extent.width,
y: renderSize.height / extent.height))
image = normalized(image)
}
if let matrix = spec.rgbMatrix, matrix.count == 16,
let filter = CIFilter(name: "CIColorMatrix") {
filter.setValue(image, forKey: kCIInputImageKey)
filter.setValue(vector(matrix, 0, 4, 8), forKey: "inputRVector")
filter.setValue(vector(matrix, 1, 5, 9), forKey: "inputGVector")
filter.setValue(vector(matrix, 2, 6, 10), forKey: "inputBVector")
filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector")
filter.setValue(vector(matrix, 12, 13, 14), forKey: "inputBiasVector")
if let output = filter.outputImage { image = output }
}
if let overlay = overlay {
image = overlay.composited(over: image)
}
return image.cropped(to: CGRect(origin: .zero, size: renderSize))
}
private static func vector(_ m: [Double], _ x: Int, _ y: Int, _ z: Int) -> CIVector {
CIVector(x: CGFloat(m[x]), y: CGFloat(m[y]), z: CGFloat(m[z]), w: 0)
}
private static func normalized(_ image: CIImage) -> CIImage {
let extent = image.extent
guard extent.origin != .zero else { return image }
return image.transformed(
by: CGAffineTransform(translationX: -extent.origin.x, y: -extent.origin.y))
}
private static func reply(_ result: @escaping FlutterResult, _ value: Any?) {
DispatchQueue.main.async { result(value) }
}
private static func reply(_ block: @escaping () -> Void) {
DispatchQueue.main.async(execute: block)
}
}