📷 Camera App

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:


🏗️ 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:

  1. Solicita autorização via controller.requestPermissions()
  2. Configura session com frame rate desejado (60fps por padrão)
  3. Instancia SegmentedRecorder e configura delegate
  4. Inicia monitoramento de orientação
  5. 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:

Exemplo:

Button("Record") {
    model.toggleRecording()
}

func deleteSegment(_ segment: RecordedSegment)

Descrição: Deleta um segmento específico.

Comportamento:

  1. Remove do array segments
  2. 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:

  1. Cria AVMutableComposition vazio
  2. Adiciona tracks de vídeo e áudio
  3. Para cada segmento:
    • Insere CMTimeRange na composição
    • Preserva preferredTransform (orientação)
  4. Aplica filtro se selecionado (via AVVideoComposition)
  5. Exporta com AVAssetExportSession (preset: .highestQuality)
  6. Salva no Photos via PHPhotoLibrary.performChanges()
  7. Remove arquivos temporários
  8. 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:

Comportamento:

Exemplo:

ForEach(0..<3) { index in
    Button("\(zoomLabel(index))") {
        model.selectQuickZoom(index: index)
    }
}

func toggleTorch()

Descrição: Liga/desliga o torch (flash).

Comportamento:

Exemplo:

Button(action: { model.toggleTorch() }) {
    Image(systemName: model.isTorchOn ? "bolt.fill" : "bolt")
}

func toggleCameraPosition()

Descrição: Alterna entre câmera frontal e traseira.

Comportamento:

  1. Chama controller.toggleCameraPosition()
  2. Reseta torch state se necessário
  3. Reaplica configurações (frame rate, zoom)

Exemplo:

Button("Flip") {
    model.toggleCameraPosition()
}

func toggleFrameRate()

Descrição: Alterna entre 30fps e 60fps.

Comportamento:

  1. Atualiza desiredFrameRate (.fps30.fps60)
  2. Reconfigura session via controller.configureSession()
  3. 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:

  1. Gera thumbnail do vídeo com AVAssetImageGenerator
    • Frame em 0.05 segundos
    • appliesPreferredTrackTransform = true (orientação correta)
  2. Cria RecordedSegment com URL e thumbnail
  3. 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:


🎬 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

Background Threads

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:

Vídeo final com orientação errada

Problema: Vídeo salvo está rotacionado.

Solução:

Export falha silenciosamente

Problema: nextAction() não salva vídeo.

Solução:


📚 Ver Também


← Componentes CaptureSessionController →