Skip to content

Latest commit

 

History

History
171 lines (118 loc) · 15.7 KB

DEVREADME.md

File metadata and controls

171 lines (118 loc) · 15.7 KB

Cover

GrabShot

Приложение macOS для создания серии скриншотов из видео файла.

GrabShotReview.mov

Содержание

Начало

В работе медиа, при планировании рекламных роликов и фильмов, требуется создание мудборд презентаций. Презентации состоят обычно из кадров других похожих работ. Чтоб облегчить поиск и время на отсмотр вдохновляющих материалов, я решил написать приложение которое автоматизирует этот процесс. Исходные навыки дала мне школа GeekBrains, где я учюсь на разработчика iOS приложений. Бибилотеки реализации приложений на iOS и macOS одни и теже, поэтому я решил написать такую утилиту.

Задачи

  • Импорт серии видео файлов в приложение
  • Захват изображений с заданным интервалом
  • Создание штрихкода цвета для видео
  • Сохранение файлов захвата на диск
  • Настройки для параметров захвата

Обзор

Возможности

Импорт видео

На стадии задумки я хотел чтоб пользователь мог загрзить сразу серию видео файлов и самаы быстрым и понятным способом оказался - Drag&Drop. Поэтому первое окно при запуске - это поле куда пользователь может перетащить видео файлы.

Drop

Так же есть классический способ выбрать файл через панель навигации приложения сверху "Файл -> Выбрать Видео" или горячие клавиши ⌘ + O.

Import

На панели навигации приложения есть элемент с вкладками, чтоб пользователь мог переключаться между рабочами окнами

Захват изображений

После импорта видео файла, приложение автоматически переключается на вкладку захвата изображений. Здесь таблица, где мы видим выбранное видео с информацией расположения файла в виде ссылки, длительность, колличество кадров захвата на выходе при текущем интервале внизу и шкала прогресса. Ниже настроек есть поле для цветового штрихкода, которое будет динамически рисоваться с каждым кадром захвата. Barcode - это цветовая палитра кадров в фильме, она отражает цветовой характер использованных цветов и оттенков в произведении. На поле штрихкода есть кнопка для просмотра штрихкода. Последняя зона - это общий прогресс очереди захвата с кнопками запуска/остановки и отмены.

2 GrabQueueTab

При запуске процесса по кнопке Старт, начинается захват кадров. Над школой прогресса есть лог описание что происходит в данный момент, какой текущий кадр и сколько их всего.

3 Grabbing

И конечно есть возможность сделать скриншоты для всех импортированных файлов. Прогресс в таблице показывает состояние каждого, а общий всей очереди.

2 GrabQueueTab 2

Результат

Кадры

Получившиеся кадры в процессе сохраняются на диск в автоматически созданную папку с тем же названием что и видео файл. Местоположение папки то же что и файл. Название кадров - это название видео файла с суффиксом таймкода.

Output

Штрихкод

Получившийся цветовой штрихкод можно посомтреть по кнопке на его поле. Файл сохраняется тоже в папку с кадрами.

StripPreview

Ниже приведены несколько штрихкодов из разных фильмов.

Настройки

Работу приложения можно настроить. Запуск окна для этого лежит в интуитивном месте - в верхней панели системы по нажатию на назвние программы или комюинацией ⌘ + ,.

SettingsNavigation

Окно настроек делится на две вкладки.

Настройки захвата

Здесь есть ползунок для выбора степени сжатия JPG ихображений. И переключатель открытия папки с получишимися изображениями в финале процесса захвата.

GrabSettings

Настройки штрихкода

Штрихкод нужен для разных задач и какой он должен быть - должен определит пользователь. На каждом кадре определяется средний цвет или цвета, их колличество можно выбрать. Разрешение конечного изображения может понадобится большим или наоборот маленьким, поэтому есть поля для размера в пикселях.

StripSettings Colors

Реализация

Использованные ресурсы:

Интерфейс

Интерфейс я решил написать на SwiftUI. Причины этого решения:

  • Хотелось закрепить знания этого фреймворка, которые я получил в IT школе
  • Приложение не сложное, поэтому проблемы нового фреймворка по отношению не должны были возникнуть
  • Синтаксис фреймворка одинаковый для мобильных приложений и для macOS
  • В SwiftUI есть предикаты, которые делают код реактивным, что уменбшает колличество кода
  • Научится работать с предикатами на практике

Захват

Я провел поиск подходящего мультимедиа фремворка и рассматривал следующие:

Как правило весь видео материал кино и сериалов в сети формата MKV, а встреонный AVFoundation его не поддерживает и сразу отпал.

VLCKit поддерживает MKV имеет простую документацию, написан на Objective-C. Имопртируется Pod или Package Manager. Но возникла проблема в получении серии скриншотов, они дублировались по неизвестным причинам. То есть таймкод новый в функции, а библиотека выдает прошлый кадр.

Решил попробовать FFmpeg, тут сложнее, потому что общение с фреймфорком требуется через командую строку, но это возможно. На этот раз результат был всегда точный. Есть wrapper для удобного общения в FFmpeg. Я использовал FFmpegKit.

static func grab(in video: Video, timecode: TimeInterval, quality: Double, completion: @escaping (Result<URL,Error>) -> Void) {
if timecode == .zero {
FileService.makeDir(for: video.url)
}
let urlRelativeString = video.url.relativePath
let qualityReduced = (100 - quality).rounded() / 10
let timecodeFormatted = self.timecodeString(for: timecode)
let urlExport = video.url.deletingPathExtension()
let urlImage = urlExport.appendingPathComponent(video.title)
.appendingPathExtension(timecodeFormatted)
.appendingPathExtension("jpg")
let arguments = [
"-y", //Overwrite output files without asking
"-ss", "\(timecode)",
"-i", "'\(urlRelativeString)'",
"-vframes", "1", //Set the number of video frames to output
"-q:v", "\(qualityReduced)",
"'\(urlImage.relativePath)'"
]
let command = arguments.joined(separator: " ")
FFmpegKit.executeAsync(command) { session in
guard let state = session?.getState() else { return }
switch state {
case .completed:
completion(.success(urlImage))
default:
let error = VideoServiceError.grab(video: video, timecode: timecode)
completion(.failure(error))
}
}
}

Очередь операций

Серия операций захвата создается через OperationQueue

private var operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.qualityOfService = .utility
operationQueue.maxConcurrentOperationCount = 1
return operationQueue
}()
private func start(for id: Int) {
guard let video = videos.first(where: { $0.id == id }) else { return }
let operations = createOperations(for: video, with: period)
operations.forEach { operation in
operationQueue.addOperation(operation)
}
delegate?.started(video: video)
}

Ниже код создания очереди операций. В каждой определен completion block который будет сообщать когда операция заврешилась.

private func createOperations(for video: Video, with period: Int) -> [GrabOperation] {
let timecodes = timecodes(for: video)
self.timecodes[video.id] = timecodes
let grabOperations = timecodes.map { timecode in
let grabOperation = GrabOperation(video: video, timecode: timecode)
grabOperation.completionBlock = {
if let result = grabOperation.result {
switch result {
case .success(let success):
let imageURL = success
DispatchQueue.main.async {
video.progress.current += 1
}
self.delegate?.progress(for: video, isCreated: video.progress.current, on: timecode, by: imageURL)
case .failure(let failure):
self.error = failure
self.delegate?.error(failure)
}
}
self.onNextOperation(for: video)
}
return grabOperation
}
return grabOperations
}

Сама операция захвата лежит в функции main объекта Operation

class GrabOperation: AsyncOperation {
let video: Video
let timecode: TimeInterval
var durationOperation: TimeInterval = .zero
var result: Result<URL, Error>?
init(video: Video, timecode: TimeInterval) {
self.video = video
self.timecode = timecode
super.init()
}
override func main() {
let startTime = Date()
VideoService.grab(in: video, timecode: timecode, quality: Session.shared.quality) { result in
self.result = result
self.durationOperation = Date().timeIntervalSince(startTime)
self.state = .finished
}
}

Так как операция pахвата происходит асинхронно, то пришлось создасть свой класс AsyncOperation наслядуюсь от Operation:

class AsyncOperation: Operation {
enum State: String {
case ready, executing, finished
fileprivate var keyPath: String {
return "is" + rawValue.capitalized
}
}
var state = State.ready {
willSet {
willChangeValue(forKey: newValue.keyPath)
willChangeValue(forKey: state.keyPath)
}
didSet {
didChangeValue(forKey: oldValue.keyPath)
didChangeValue(forKey: state.keyPath)
}
}
}
extension AsyncOperation {
override var isReady: Bool {
return super.isReady && state == .ready
}
override var isExecuting: Bool {
return state == .executing
}
override var isFinished: Bool {
return state == .finished
}
override var isAsynchronous: Bool {
return true
}
override func start() {
if isCancelled {
state = .finished
return
}
main()
state = .executing
}
override func cancel() {
super.cancel()
state = .finished
}
}

Создание штрихкода

Принцип создания не сложный. Опишу его. Цветовая палитра одного кадра - это набор нескольких прямоугольников с усредненными цветам кадра выстроенных по горизонтали. Например она может выглядеть так.

ColotPalete

Если взять несколько таких палитр, выстроить их по горизонтали, то получится очень длинная полоса с цветовыми прямоугольниками. Далее надо их сжать до ширины экрана, чтоб видеть все сруз и вот получится цветовой штрихкод. Для создания такого изображения я использовал Core Image. Функция высчитывания средних цвета изображения

func averageColors(count: Int) -> [Color]? {
let extentVectors = [Int] (0...(count-1)).map { part in
let partWidth = self.extent.size.width / CGFloat(count)
let extentVector = CIVector(x: partWidth * CGFloat(part), y: self.extent.origin.y, z: partWidth, w: self.extent.size.height)
return extentVector
}
let filters = extentVectors.compactMap { CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: self, kCIInputExtentKey: $0]) }
let outputImages = filters.compactMap { $0.outputImage }
var bitmaps: [[UInt8]] = []
guard let kCFNull = kCFNull else { return nil }
let context = CIContext(options: [.workingColorSpace: kCFNull])
outputImages.forEach { outputImage in
var bitmap = [UInt8](repeating: 0, count: 4)
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
bitmaps.append(bitmap)
}
let colors = bitmaps.map { bitmap in
let red = CGFloat(bitmap[0]) / 255
let green = CGFloat(bitmap[1]) / 255
let blue = CGFloat(bitmap[2]) / 255
return Color(red: red, green: green, blue: blue)
}
return colors
}

Получив координаты цвета в виде Color, я создаю привычное SwiftUI view через стек прямоугольников заполненых цветом. Которые пополняются по мере обноления модели Video.

struct StripView: View {
@ObservedObject var viewModel: StripModel
var body: some View {
GeometryReader { reader in
ZStack {
HStack(spacing: 0) {
ForEach(viewModel.video?.colors ?? [Color.clear], id: \.self) { color in
Rectangle()
.fill(color)
}
.animation(.easeInOut, value: viewModel.video?.colors)
}
}
}
}
}

Сохранить получившееся вью в изорбражение можно используя ImageRenderer

@MainActor func saveImage(view: some View) {
DispatchQueue.main.async {
let view = view.frame(width: Session.shared.stripSize.width, height: Session.shared.stripSize.height)
let render = ImageRenderer(content: view)
guard
let cgImage = render.cgImage,
let video = self.video
else { return }
let name = video.title + "Strip"
let url = video.url.deletingPathExtension().appendingPathComponent(name)
do {
try FileService.shared.writeImage(cgImage: cgImage, to: url, format: .png)
} catch {
print(error)
}
}
}

ToDo

  • Перенести создание скриншотов для поддерживаемых форматов на встроенную библиотеку AVFoundation