CameraViewModel
ViewModel central da aplicação. Gerencia todo o estado da câmera e coordena as interações entre UI e camada de captura.
Arquivo: CameraViewModel.swift
📋 Visão Geral
O CameraViewModel
é o cérebro da aplicação. Ele:
- Mantém todo o estado observável da UI
- Coordena
CaptureSessionController
eSegmentedRecorder
- Processa e concatena segmentos de vídeo
- Aplica filtros e salva vídeos na galeria
- Implementa delegates de gravação
🏗️ Estrutura
class CameraViewModel: NSObject, ObservableObject {
// MARK: - Published Properties
@Published var isAuthorized: Bool = false
@Published var isSessionRunning: Bool = false
@Published var isRecording: Bool = false
@Published var segments: [RecordedSegment] = []
// ... outras propriedades
// MARK: - Dependencies
let controller: CaptureSessionController
private var recorder: SegmentedRecorder?
// MARK: - Lifecycle
func requestPermissionsAndConfigure()
func cleanup()
// MARK: - Recording
func toggleRecording()
func nextAction() // Concatenate & save
func deleteSegment(_ segment: RecordedSegment)
// MARK: - Camera Controls
func selectQuickZoom(index: Int)
func toggleTorch()
func toggleCameraPosition()
func toggleFrameRate()
}
📦 Propriedades Publicadas
Estado da Câmera
@Published var isAuthorized: Bool = false
Descrição: Autorização de câmera e microfone concedida. Uso: Controla exibição da UI principal vs tela de permissões.
@Published var isSessionRunning: Bool = false
Descrição: Estado da AVCaptureSession (rodando ou parada). Uso: Feedback visual de carregamento.
@Published var isRecording: Bool = false
Descrição: Gravação ativa no momento. Uso: Muda UI do botão de gravação, timer, etc.
Configurações de Vídeo
@Published var frameRateLabel: String = "60"
Descrição: Label do frame rate atual (“30” ou “60”). Uso: Exibição no botão de frame rate.
@Published var selectedFilter: VideoFilter = .none
Descrição: Filtro selecionado para aplicar na exportação.
Valores: .none
, .mono
Uso: Menu de filtros e aplicação durante export.
Controles de Câmera
@Published var quickZoomIndex: Int = 1
Descrição: Índice do zoom rápido (0=0.5x, 1=1x, 2=2x). Uso: Botões de quick zoom.
@Published var isTorchOn: Bool = false
Descrição: Estado do flash/torch. Uso: Ícone do botão de torch.
@Published var showGrid: Bool = false
Descrição: Exibição da grade de composição. Uso: Overlay de grid no preview.
Segmentos de Gravação
@Published var segments: [RecordedSegment] = []
Descrição: Array de segmentos gravados.
Tipo: RecordedSegment
contém url
, thumbnail
, id
Uso: Strip de thumbnails, concatenação.
Teleprompter
@Published var isTeleprompterOn: Bool = false
@Published var teleprompterText: String = ""
@Published var teleprompterSpeed: Double = 20
@Published var teleprompterFontSize: CGFloat = 24
Descrição: Estado e configurações do teleprompter. Uso: Overlay do teleprompter.
🔧 Métodos Principais
Inicialização e Permissões
func requestPermissionsAndConfigure()
Descrição: Solicita permissões e configura a câmera.
Fluxo:
- Solicita autorização via
controller.requestPermissions()
- Configura session com frame rate desejado (60fps por padrão)
- Instancia
SegmentedRecorder
e configura delegate - Inicia monitoramento de orientação
- Inicia a capture session
Uso: Chamado no .onAppear()
da ContentView
.
Exemplo:
.onAppear {
model.requestPermissionsAndConfigure()
}
Gestão de Gravação
func toggleRecording()
Descrição: Inicia ou para a gravação.
Comportamento:
- Se
!isRecording
: Chamarecorder?.startNewSegment()
- Se
isRecording
: Chamarecorder?.stopCurrentSegment()
- Atualiza
isRecording
Exemplo:
Button("Record") {
model.toggleRecording()
}
func deleteSegment(_ segment: RecordedSegment)
Descrição: Deleta um segmento específico.
Comportamento:
- Remove do array
segments
- Remove arquivo do disco via
FileManager
Exemplo:
Button("Delete") {
model.deleteSegment(segment)
}
func nextAction()
Descrição: Concatena todos os segmentos e salva na galeria.
Fluxo Completo:
- Cria
AVMutableComposition
vazio - Adiciona tracks de vídeo e áudio
- Para cada segmento:
- Insere
CMTimeRange
na composição - Preserva
preferredTransform
(orientação)
- Insere
- Aplica filtro se selecionado (via
AVVideoComposition
) - Exporta com
AVAssetExportSession
(preset:.highestQuality
) - Salva no Photos via
PHPhotoLibrary.performChanges()
- Remove arquivos temporários
- Limpa array
segments
Exemplo:
Button("Next") {
model.nextAction()
}
Controles de Câmera
func selectQuickZoom(index: Int)
Descrição: Aplica zoom rápido (0.5x / 1x / 2x).
Parâmetros:
index
: 0 (0.5x), 1 (1x), 2 (2x)
Comportamento:
- 0 (0.5x): Tenta usar câmera ultra-wide ou zoom digital mínimo
- 1 (1x): Retorna para câmera wide padrão
- 2 (2x): Aplica zoom 2x (pode acionar telephoto automaticamente)
Exemplo:
ForEach(0..<3) { index in
Button("\(zoomLabel(index))") {
model.selectQuickZoom(index: index)
}
}
func toggleTorch()
Descrição: Liga/desliga o torch (flash).
Comportamento:
- Back camera: Usa torch de hardware via
AVCaptureDevice
- Front camera: Simula aumentando brilho da tela para 1.0
Exemplo:
Button(action: { model.toggleTorch() }) {
Image(systemName: model.isTorchOn ? "bolt.fill" : "bolt")
}
func toggleCameraPosition()
Descrição: Alterna entre câmera frontal e traseira.
Comportamento:
- Chama
controller.toggleCameraPosition()
- Reseta torch state se necessário
- Reaplica configurações (frame rate, zoom)
Exemplo:
Button("Flip") {
model.toggleCameraPosition()
}
func toggleFrameRate()
Descrição: Alterna entre 30fps e 60fps.
Comportamento:
- Atualiza
desiredFrameRate
(.fps30
↔.fps60
) - Reconfigura session via
controller.configureSession()
- Atualiza label (
frameRateLabel
)
Exemplo:
Button(model.frameRateLabel) {
model.toggleFrameRate()
}
🔗 Delegates Implementados
SegmentedRecorderDelegate
extension CameraViewModel: SegmentedRecorderDelegate {
func recorder(_ recorder: SegmentedRecorder, didFinishSegment url: URL)
func recorder(_ recorder: SegmentedRecorder, didFailWith error: Error)
}
didFinishSegment
Descrição: Chamado quando um segmento termina de gravar.
Implementação:
- Gera thumbnail do vídeo com
AVAssetImageGenerator
- Frame em 0.05 segundos
appliesPreferredTrackTransform = true
(orientação correta)
- Cria
RecordedSegment
com URL e thumbnail - Adiciona ao array
segments
na main thread
Código:
func recorder(_ recorder: SegmentedRecorder, didFinishSegment url: URL) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let thumbnail = self?.generateThumbnail(for: url) else { return }
let segment = RecordedSegment(url: url, thumbnail: thumbnail)
DispatchQueue.main.async {
self?.segments.append(segment)
}
}
}
didFailWith
Descrição: Chamado quando gravação falha.
Implementação:
- Loga erro detalhado
- Reseta
isRecording = false
- Exibe alerta (se implementado)
🎬 Concatenação de Segmentos
Fluxo Detalhado
private func concatenateAndSaveSegments()
1. Criar Composição
let composition = AVMutableComposition()
let videoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
)
let audioTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
)
2. Inserir Segmentos Sequencialmente
var currentTime = CMTime.zero
for segment in segments {
let asset = AVAsset(url: segment.url)
if let assetVideoTrack = asset.tracks(withMediaType: .video).first {
try videoTrack?.insertTimeRange(
CMTimeRange(start: .zero, duration: asset.duration),
of: assetVideoTrack,
at: currentTime
)
// Preserva orientação
videoTrack?.preferredTransform = assetVideoTrack.preferredTransform
}
// Insert audio track...
currentTime = CMTimeAdd(currentTime, asset.duration)
}
3. Aplicar Filtro (Opcional)
if selectedFilter != .none {
let videoComposition = AVVideoComposition(asset: composition) { request in
let src = request.sourceImage.clampedToExtent()
let filter = CIFilter.photoEffectMono()
filter.inputImage = src
let output = filter.outputImage?.cropped(to: request.sourceImage.extent)
request.finish(with: output, context: nil)
}
exporter.videoComposition = videoComposition
}
4. Exportar
let exporter = AVAssetExportSession(
asset: composition,
presetName: AVAssetExportPresetHighestQuality
)
exporter.outputURL = tempOutputURL
exporter.outputFileType = .mov
exporter.exportAsynchronously {
// Handle completion
}
5. Salvar em Photos
PHPhotoLibrary.shared().performChanges({
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .video, fileURL: outputURL, options: nil)
}) { success, error in
// Handle result
}
6. Cleanup
// Remove segmentos individuais
for segment in segments {
try? FileManager.default.removeItem(at: segment.url)
}
// Remove arquivo temporário final
try? FileManager.default.removeItem(at: tempOutputURL)
// Limpa UI
DispatchQueue.main.async {
self.segments.removeAll()
}
🧵 Threading
Main Thread
- Todas as propriedades
@Published
- Atualizações de UI
- Callbacks de delegates (após async work)
Background Threads
- Geração de thumbnails (
.userInitiated
) - Concatenação e export (
.utility
) - Operações de FileManager
Exemplo de Pattern:
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let thumbnail = self?.generateThumbnail(for: url)
DispatchQueue.main.async {
self?.segments.append(segment)
}
}
🔍 Tipos Relacionados
RecordedSegment
struct RecordedSegment: Identifiable, Equatable {
let id = UUID()
let url: URL // Arquivo .mov em temp
let thumbnail: UIImage // Thumbnail do primeiro frame
let createdAt = Date()
}
DesiredFrameRate
enum DesiredFrameRate {
case fps30
case fps60
}
VideoFilter
enum VideoFilter: String, CaseIterable {
case none
case mono
var displayName: String { /* ... */ }
}
🎯 Casos de Uso
Gravar Vídeo Simples
// 1. Iniciar gravação
model.toggleRecording()
// 2. Usuário grava...
// 3. Parar gravação
model.toggleRecording()
// 4. Salvar
model.nextAction()
Gravar Múltiplos Takes
// Take 1
model.toggleRecording()
// ... grava ...
model.toggleRecording()
// Take 2
model.toggleRecording()
// ... grava ...
model.toggleRecording()
// Take 3
model.toggleRecording()
// ... grava ...
model.toggleRecording()
// Concatenar todos e salvar
model.nextAction()
Deletar Take Indesejado
// Visualizar takes
ForEach(model.segments) { segment in
ThumbnailView(segment: segment)
.contextMenu {
Button("Delete", role: .destructive) {
model.deleteSegment(segment)
}
}
}
🐛 Troubleshooting
Thumbnails não aparecem
Problema: Thumbnails ficam pretos ou não aparecem.
Solução:
- Verificar se
appliesPreferredTrackTransform = true
emAVAssetImageGenerator
- Verificar se vídeo tem frames válidos (não corrompido)
Vídeo final com orientação errada
Problema: Vídeo salvo está rotacionado.
Solução:
- Garantir que
preferredTransform
está sendo preservado:videoTrack?.preferredTransform = assetVideoTrack.preferredTransform
Export falha silenciosamente
Problema: nextAction()
não salva vídeo.
Solução:
- Verificar logs de erro em
exporter.error
- Verificar permissões de Photos
- Verificar espaço em disco
📚 Ver Também
- CaptureSessionController - Configuração da câmera
- SegmentedRecorder - Sistema de gravação
- Fluxo de Dados - Como os dados fluem
- Guia de Gravação - Tutorial prático
← Componentes | CaptureSessionController → |