Skip to content

Conty-App/camera-swift

Repository files navigation

Camera App - Because the default iOS camera sucks

Swift iOS Xcode


📚 Documentação Completa e Detalhada: Este README contém informações técnicas extensas. Para uma experiência de leitura otimizada com navegação intuitiva, tema dark minimalista e estrutura organizada, acesse a Documentação Oficial no GitHub Pages 🚀


Índice

  1. Visão Geral
  2. Arquitetura
  3. Componentes Principais
  4. Fluxo de Dados
  5. Gravação Segmentada
  6. Sistema de Teleprompter
  7. Sistema de Câmera
  8. Filtros de Vídeo
  9. Escolhas Técnicas
  10. Setup e Requisitos
  11. 📚 Documentação Online

Visão Geral

Aplicação de câmera profissional construída em SwiftUI com suporte a gravação segmentada, teleprompter flutuante redimensionável, filtros em tempo real e controles avançados de câmera. A aplicação foi projetada para gravação de vídeo com qualidade cinematográfica, oferecendo controles granulares sobre todos os aspectos da captura.

Preview da Interface

Camera com Teleprompter      Camera sem Teleprompter

Interface com teleprompter ativo | Interface limpa sem teleprompter

 

Camera Filters      Camera Takes

Seleção de filtros ao vivo | Visualização de segmentos gravados

Funcionalidades Principais

  • Gravação Segmentada: Sistema de takes múltiplos com preview individual e concatenação automática
  • Teleprompter Dinâmico: Overlay flutuante com rolagem automática, controles de velocidade e tamanho de fonte
  • Filtros em Tempo Real: Aplicação de filtros Core Image durante a exportação
  • Controles Profissionais: Zoom com pinch, foco/exposição por toque, alternância 0.5x/1x/2x
  • Estabilização Cinemática: Uso de AVCaptureVideoStabilizationMode.cinematic quando disponível
  • Codificação HEVC/H.264: Preferência automática por HEVC com fallback para H.264
  • Orientação Dinâmica: Suporte a todas as orientações com aplicação correta do preferredTransform

Arquitetura

Padrão Arquitetural

A aplicação utiliza MVVM (Model-View-ViewModel) com comunicação reativa via Combine, garantindo separação clara de responsabilidades e testabilidade.

┌─────────────────────────────────────────────────────────┐
│                    SwiftUI Views                        │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ ContentView  │  │ Teleprompter │  │ CameraPreview│  │
│  │              │  │   Overlay    │  │     View     │  │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘  │
│         │                 │                 │           │
└─────────┼─────────────────┼─────────────────┼───────────┘
          │                 │                 │
          ▼                 ▼                 ▼
┌─────────────────────────────────────────────────────────┐
│                     ViewModels                          │
│  ┌──────────────────────────┐  ┌─────────────────────┐ │
│  │   CameraViewModel        │  │ TeleprompterViewModel│ │
│  │  @Published properties   │  │  @Published props    │ │
│  │  Business logic          │  │  Scroll management   │ │
│  └────────┬─────────────────┘  └─────────────────────┘ │
│           │                                             │
└───────────┼─────────────────────────────────────────────┘
            │
            ▼
┌─────────────────────────────────────────────────────────┐
│               Controllers & Services                    │
│  ┌──────────────────────┐  ┌──────────────────────┐    │
│  │ CaptureSession       │  │ SegmentedRecorder    │    │
│  │   Controller         │  │                      │    │
│  │ - Session management │  │ - Recording segments │    │
│  │ - Device config      │  │ - Delegate callbacks │    │
│  │ - Format selection   │  │                      │    │
│  └──────────────────────┘  └──────────────────────┘    │
└─────────────────────────────────────────────────────────┘
            │
            ▼
┌─────────────────────────────────────────────────────────┐
│                  AVFoundation                           │
│         AVCaptureSession • AVCaptureDevice              │
│    AVCaptureMovieFileOutput • AVCaptureDeviceInput      │
└─────────────────────────────────────────────────────────┘

Responsabilidades por Camada

View (SwiftUI)

  • Renderização da interface
  • Captura de gestos do usuário
  • Binding bidirecional com ViewModels via @Published

ViewModel

  • Estado da aplicação (@Published properties)
  • Lógica de negócio e coordenação
  • Transformação de dados para a View
  • Comunicação com Controllers

Controller/Service

  • Gerenciamento direto de APIs do sistema
  • Configuração e controle do AVCaptureSession
  • Gravação e processamento de arquivos
  • Operações assíncronas em queues dedicadas

AVFoundation

  • Camada de hardware/sistema
  • Captura de vídeo e áudio
  • Codificação e gravação em disco

Componentes Principais

1. CameraViewModel

Arquivo: CameraViewModel.swift

ViewModel central da aplicação. Gerencia todo o estado da câmera e coordena as interações entre UI e camada de captura.

Propriedades Publicadas

@Published var isAuthorized: Bool                    // Autorização de câmera/microfone
@Published var isSessionRunning: Bool                // Estado da session AVCapture
@Published var isRecording: Bool                     // Estado de gravação ativa
@Published var frameRateLabel: String                // Label do frame rate (30/60 fps)
@Published var quickZoomIndex: Int                   // Índice do zoom rápido (0=0.5x, 1=1x, 2=2x)
@Published var isTorchOn: Bool                       // Estado do flash/torch
@Published var selectedFilter: VideoFilter           // Filtro selecionado
@Published var showGrid: Bool                        // Exibição da grade de composição
@Published var segments: [RecordedSegment]           // Array de segmentos gravados
@Published var isTeleprompterOn: Bool                // Ativação do teleprompter
@Published var teleprompterText: String              // Texto do roteiro
@Published var teleprompterSpeed: Double             // Velocidade de rolagem (pts/s)
@Published var teleprompterFontSize: CGFloat         // Tamanho da fonte

Funcionalidades Principais

Inicialização e Permissões

func requestPermissionsAndConfigure()
  • Solicita autorização para câmera e microfone via CaptureSessionController
  • Configura a session com frame rate desejado (60fps por padrão)
  • Instancia o SegmentedRecorder e configura delegates
  • Inicia monitoramento de orientação do dispositivo
  • Inicia a capture session

Gestão de Zoom

func selectQuickZoom(index: Int)
  • 0.5x: Tenta usar câmera ultra-wide física ou zoom digital até o mínimo disponível
  • 1x: Retorna para câmera wide ou zoom 1.0
  • 2x: Zoom digital 2x (em dispositivos com virtual device, pode acionar telephoto automaticamente)

Torch (Flash)

func toggleTorch()
  • Câmera traseira: Usa torch de hardware via AVCaptureDevice
  • Câmera frontal: Simula torch aumentando o brilho da tela para 1.0 (salva brilho original)

Gravação de Segmentos

func toggleRecording()
  • Inicia novo segmento via SegmentedRecorder.startNewSegment()
  • Para segmento atual via SegmentedRecorder.stopCurrentSegment()
  • Atualiza isRecording para controle de UI

Processamento de Segmentos

func nextAction()

Fluxo completo de concatenação e salvamento:

  1. Cria AVMutableComposition vazio
  2. Adiciona tracks de vídeo e áudio
  3. Para cada segmento, insere CMTimeRange na composição
  4. Preserva preferredTransform para orientação correta
  5. Aplica filtro se selecionado (via AVVideoComposition)
  6. Exporta com AVAssetExportSession (preset: highestQuality)
  7. Salva no Photos via PHPhotoLibrary
  8. Remove arquivos temporários
  9. Limpa array segments

Delegates Implementados

SegmentedRecorderDelegate

func recorder(_ recorder: SegmentedRecorder, didFinishSegment url: URL)
  • Gera thumbnail do vídeo com AVAssetImageGenerator (frame em 0.05s)
  • Cria RecordedSegment com URL e thumbnail
  • Adiciona ao array segments na main thread
func recorder(_ recorder: SegmentedRecorder, didFailWith error: Error)
  • Loga erro detalhado
  • Reseta isRecording = false

2. CaptureSessionController

Arquivo: CaptureSessionController.swift

Controller de baixo nível que encapsula toda a complexidade do AVCaptureSession. Gerencia dispositivos de captura, formatos de vídeo, zoom, foco, exposição e estabilização.

Arquitetura Interna

CaptureSessionController
├── session: AVCaptureSession
├── sessionQueue: DispatchQueue              // Queue serial para thread-safety
├── videoDevice: AVCaptureDevice?            // Dispositivo de câmera atual
├── videoDeviceInput: AVCaptureDeviceInput?
├── audioDeviceInput: AVCaptureDeviceInput?
└── movieFileOutput: AVCaptureMovieFileOutput?

Métodos Principais

Configuração de Session

func configureSession(
    desiredFrameRate: DesiredFrameRate = .fps60,
    position: AVCaptureDevice.Position = .front,
    completion: ((Error?) -> Void)? = nil
)

Executa na sessionQueue:

  1. session.beginConfiguration()
  2. Define preset .high
  3. Encontra melhor câmera via findBestCamera(for:):
    • Back: Prefere .builtInTripleCamera > .builtInDualWideCamera > .builtInDualCamera > .builtInWideAngleCamera
    • Front: Prefere .builtInTrueDepthCamera > .builtInWideAngleCamera
  4. Remove inputs existentes
  5. Adiciona video input e audio input
  6. Configura frame rate via setFrameRateLocked(to:)
  7. Adiciona AVCaptureMovieFileOutput se necessário
  8. Aplica estabilização cinemática
  9. Configura mirroring (front = espelhado, back = normal)
  10. Define codec HEVC como preferido
  11. Força zoom 1.0 inicial
  12. session.commitConfiguration()

Seleção de Formato e Frame Rate

private func setFrameRateLocked(to fps: Int) throws

Algoritmo de seleção de formato:

  1. Filtra device.formats para encontrar formatos que suportam o FPS desejado
  2. Para cada formato, verifica videoSupportedFrameRateRanges
  3. Seleciona formato com maior resolução (compara width * height)
  4. Define device.activeFormat
  5. Configura activeVideoMinFrameDuration e activeVideoMaxFrameDuration para o FPS exato

Zoom com Ramping

func setZoomFactor(_ factor: CGFloat, animated: Bool, rampRate: Float)
  • Clamp: max(minAvailableVideoZoomFactor, min(6.0, factor))
  • Limite de 6.0x para evitar degradação de qualidade digital
  • Animated = true: usa ramp(toVideoZoomFactor:withRate:) para transição suave
  • Animated = false: define videoZoomFactor diretamente

Foco e Exposição por Toque

func focusAndExpose(at devicePoint: CGPoint)
  • Recebe devicePoint convertido da UI (0.0-1.0 em x,y)
  • Configura focusPointOfInterest e focusMode = .continuousAutoFocus
  • Configura exposurePointOfInterest e exposureMode = .continuousAutoExposure
  • Habilita isSubjectAreaChangeMonitoringEnabled

Alternância de Câmera

func toggleCameraPosition()

Processo:

  1. Determina próxima posição (front ↔ back)
  2. Encontra melhor dispositivo para a posição via findBestCamera(for:)
  3. Troca input via useDevice(_:) (begin/commit configuration)
  4. Reaplica frame rate configurado
  5. Reseta zoom para 1.0
  6. Reaplica estado do torch (se suportado)
  7. Atualiza mirroring

Jump Zooms (0.5x / 1x / 2x)

func jumpToHalfX()
  • Se virtual device suporta 0.5x (minAvailableVideoZoomFactor ≤ 0.5): aplica zoom digital
  • Senão, tenta trocar para câmera ultra-wide física (.builtInUltraWideCamera)
  • Mantém frame rate configurado após troca
func jumpToOneX()
  • Se atualmente em ultra-wide física: troca de volta para .builtInWideAngleCamera
  • Senão, apenas aplica zoom 1.0
func jumpToTwoX()
  • Aplica zoom 2.0x (em dispositivos com telephoto, o virtual device troca automaticamente)

Estabilização

func applyPreferredStabilizationMode()
  • Itera por movieFileOutput.connections
  • Para cada connection com mediaType == .video
  • Define preferredVideoStabilizationMode = .cinematic se suportado

Orientação

func setVideoOrientation(_ orientation: AVCaptureVideoOrientation)
  • Define orientação no AVCaptureConnection do movieFileOutput
  • Sincroniza mirroring com a posição atual da câmera

3. SegmentedRecorder

Arquivo: SegmentedRecorder.swift

Gerencia a gravação de múltiplos segmentos de vídeo independentes, cada um em um arquivo temporário separado.

Protocol SegmentedRecorderDelegate

protocol SegmentedRecorderDelegate: AnyObject {
    func recorder(_ recorder: SegmentedRecorder, didFinishSegment url: URL)
    func recorder(_ recorder: SegmentedRecorder, didFailWith error: Error)
}

Fluxo de Gravação

┌─────────────────────────────────────────────────────────────┐
│                     User Action                             │
│              toggleRecording() pressed                       │
└────────────────────┬────────────────────────────────────────┘
                     │
         ┌───────────▼──────────┐
         │  isRecording == false │
         └───────────┬───────────┘
                     │
         ┌───────────▼──────────────────────────┐
         │  startNewSegment()                   │
         │  1. Create temp URL                  │
         │  2. Set video orientation            │
         │  3. output.startRecording(to:)       │
         └───────────┬──────────────────────────┘
                     │
         ┌───────────▼──────────────────────────┐
         │  Recording in progress...            │
         │  isRecording = true                  │
         └───────────┬──────────────────────────┘
                     │
         ┌───────────▼──────────┐
         │  User stops recording│
         └───────────┬───────────┘
                     │
         ┌───────────▼──────────────────────────┐
         │  stopCurrentSegment()                │
         │  output.stopRecording()              │
         └───────────┬──────────────────────────┘
                     │
         ┌───────────▼──────────────────────────────────┐
         │  AVCaptureFileOutputRecordingDelegate        │
         │  fileOutput(_:didFinishRecordingTo:...)      │
         └───────────┬──────────────────────────────────┘
                     │
         ┌───────────▼──────────────────────────┐
         │  delegate.recorder(didFinishSegment:)│
         │  CameraViewModel receives URL         │
         └───────────┬──────────────────────────┘
                     │
         ┌───────────▼──────────────────────────┐
         │  Generate thumbnail                  │
         │  Create RecordedSegment              │
         │  Append to segments array            │
         └──────────────────────────────────────┘

Características

  • Cada segmento é um arquivo .mov independente em NSTemporaryDirectory()
  • Nomes de arquivo: segment_{UUID}.mov
  • Orientação é atualizada via updateOrientation(from:) quando dispositivo rotaciona
  • Propriedade saveToPhotoLibrary: se true, salva cada segmento individualmente (no nosso caso, false)

4. TeleprompterOverlay

Arquivo: TeleprompterOverlay.swift

Componente de UI complexo que renderiza um overlay flutuante, redimensionável e arrastável sobre o preview da câmera, com rolagem automática de texto.

Estrutura de Componentes

TeleprompterOverlay
├── TeleprompterTextView (UIViewRepresentable → UITextView)
│   ├── Coordinator (UITextViewDelegate)
│   ├── Scroll programático via contentOffset binding
│   └── Cálculo de contentSize para height dinâmico
├── TeleprompterViewModel (@ObservableObject)
│   ├── Gestão de scroll automático com Timer
│   ├── Cálculo de content height
│   ├── Gestão de interações (drag, resize)
│   └── Estado de play/pause
├── PlayPauseButton
├── ResizeHandle (bottom-right)
├── MoveHandle (bottom-left)
└── BottomSlidersBar
    ├── Font size slider
    └── Speed slider

TeleprompterViewModel

Responsabilidades

  1. Scroll Automático
func startScrolling(speed: Double, viewportHeight: CGFloat)
  • Timer com intervalo de 1.0 / 60.0 (60fps)
  • A cada tick, incrementa contentOffset += speed * deltaTime
  • Quando atinge contentOffset >= maxOffset:
    • Se pauseAtEnd = true: para o timer e pausa
    • Se pauseAtEnd = false: reseta para 0 (loop)
  1. Cálculo de Content Height
func updateContentHeight(text: String, fontSize: CGFloat, width: CGFloat)
  • Cria NSAttributedString com mesmas propriedades do UITextView
  • Usa boundingRect(with:options:attributes:) para medir altura
  • Adiciona padding vertical configurado
  • Cacheia resultado com signature {text.hashValue}|{fontSize}|{Int(width)}
  1. Interações de Drag/Resize
func updateOverlayPosition(translation: CGSize)
func finalizeOverlayPosition(parentSize: CGSize)
func resizeOverlay(translation: CGSize, parentSize: CGSize)
func finalizeResize()
  • Marca isInteracting = true durante gestos
  • Debounce de cálculos pesados (height update/clamp) via DispatchWorkItem
  • Finalização aplica constraints (limites de viewport)
  1. Sincronização com Gravação
func handleRecordingStateChange(isRecording: Bool, speed: Double, viewportHeight: CGFloat)
  • Se isRecording = true: inicia scroll automático
  • Se isRecording = false: para scroll mas mantém offset
  • Ajusta padding do viewport (compact vs normal)

TeleprompterTextView (UIViewRepresentable)

Bridge entre SwiftUI e UIKit para controle preciso de scroll.

Coordinator

class Coordinator: NSObject, UITextViewDelegate {
    var isProgrammaticScroll: Bool
    func scrollViewDidScroll(_ scrollView: UIScrollView)
}
  • Flag isProgrammaticScroll evita loop infinito (binding → scroll → binding)
  • scrollViewDidScroll: atualiza binding somente se scroll foi manual (usuário)

Configuração do UITextView

tv.isEditable = false
tv.isSelectable = false
tv.isScrollEnabled = true
tv.isUserInteractionEnabled = userInteractionEnabled
tv.textContainerInset = UIEdgeInsets(top: topInset, left: horizontalPadding, ...)
tv.textContainer.lineFragmentPadding = 0

Atualização de contentOffset

if abs(currentY - contentOffset) > 0.5 {
    context.coordinator.isProgrammaticScroll = true
    tv.setContentOffset(CGPoint(x: 0, y: contentOffset), animated: false)
    context.coordinator.isProgrammaticScroll = false
}

Gestos Implementados

Tap: Abre editor fullscreen (isEditorPresented = true) Drag (MoveHandle): Move o overlay pela tela Drag (ResizeHandle): Redimensiona o overlay (diagonal) Pinch: Não implementado (zoom é via slider)

Configuração (TeleprompterConfig)

static let minFontSize: CGFloat = 18
static let maxFontSize: CGFloat = 36
static let minSpeed: Double = 8           // pts/segundo
static let maxSpeed: Double = 60
static let scrollFrameRate: Double = 60.0
static let viewportPadding: CGFloat = 56         // Padding normal
static let compactViewportPadding: CGFloat = 36  // Padding durante gravação

5. ContentView

Arquivo: ContentView.swift

View principal da aplicação. Orquestra todos os componentes visuais e coordena interações do usuário com o CameraViewModel.

Estrutura Visual

ZStack {
    Color.black                          // Background
    
    GeometryReader {
        CameraPreviewRepresentable       // Preview da câmera (aspect 16:9)
        GridOverlay                      // Grade de composição (opcional)
        FilterOverlay                    // Hint visual do filtro
    }
    
    VStack {
        TopControls (se !isRecording)
        ┌────────────────────────────────────────┐
        │ Close | FrameRate | Grid | Torch | TP │
        └────────────────────────────────────────┘
        
        RecordingCountdown (se isRecording)
        ┌────────────────────────────────────────┐
        │           [00:00] timer                │
        └────────────────────────────────────────┘
        
        TeleprompterOverlay (se isTeleprompterOn)
        
        Spacer
        
        QuickZoomButtons [0.5x | 1x | 2x]
        RecordButton (círculo vermelho)
    }
    
    SegmentThumbnailStrip (se !segments.isEmpty)
    ┌────────────────────────────────────────┐
    │ [thumb1] [thumb2] [thumb3] ...         │
    └────────────────────────────────────────┘
    
    FilterButton + FilterMenu (bottom-left)
    CameraToggleButton (bottom-right)
    NextButton (se !segments.isEmpty)
}

Interações de Usuário

Gestos no Preview

Configurados via CameraPreviewView e closures:

view.onTapToFocus = { devicePoint in
    controller.focusAndExpose(at: devicePoint)
}
view.onPinch = { scale, state in
    // baseZoom armazenado em .began
    // .changed: setZoomFactor(baseZoom * scale)
    // .ended: cancelZoomRamp()
}
view.onDoubleTap = {
    toggleCameraPosition()
}

Botão de Gravação

Button(action: { model.toggleRecording() }) {
    ZStack {
        Circle()  // Borda branca 80x80
        RoundedRectangle(cornerRadius: isRecording ? 12 : 38)
            .fill(Color.red)
            .frame(width: isRecording ? 42 : 76, height: isRecording ? 42 : 76)
    }
}
  • Animação: círculo → quadrado arredondado ao gravar
  • Duração: 0.3s com .easeInOut

Menu de Filtros

@State private var showFilterPicker: Bool = false
  • Botão de varinha mágica abre/fecha menu vertical
  • Exibe FilterMenu com lista de filtros disponíveis
  • Cada filtro tem swatch visual e checkmark quando selecionado

Strip de Segmentos

ScrollView(.horizontal) {
    ForEach(model.segments) { seg in
        ZStack(alignment: .topTrailing) {
            Button { previewSegment = seg }           // Abre preview fullscreen
            Button { showDeleteConfirm = true }       // X para deletar
        }
    }
}

Sheets e Alerts

Preview de Segmento

.sheet(item: $previewSegment) {
    SegmentPlaybackView(segment:onDelete:onClose:)
}
  • Exibe AVPlayer em aspect 9:16
  • Botão de delete com confirmação
  • Barra de navegação com "Fechar"

Confirmação de Delete

.alert("Deseja apagar esse take?", isPresented: $showDeleteConfirm)

Timer de Countdown

private func startCountdown() {
    countdown = 0
    countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
        countdown += 1
    }
}
  • Formata como MM:SS via timeString(from:)
  • Reseta ao parar gravação

Fluxo de Dados

Fluxo de Gravação Completo

sequenceDiagram
    participant User
    participant ContentView
    participant CameraViewModel
    participant SegmentedRecorder
    participant AVFoundation
    participant FileSystem
    participant PhotosApp

    User->>ContentView: Tap Record Button
    ContentView->>CameraViewModel: toggleRecording()
    CameraViewModel->>SegmentedRecorder: startNewSegment()
    SegmentedRecorder->>FileSystem: Create temp URL
    SegmentedRecorder->>AVFoundation: output.startRecording(to: tempURL)
    AVFoundation-->>SegmentedRecorder: didStartRecordingTo
    SegmentedRecorder-->>CameraViewModel: (recording started)
    CameraViewModel-->>ContentView: isRecording = true
    ContentView-->>User: Show countdown timer
    
    Note over User,AVFoundation: User records video...
    
    User->>ContentView: Tap Stop Button
    ContentView->>CameraViewModel: toggleRecording()
    CameraViewModel->>SegmentedRecorder: stopCurrentSegment()
    SegmentedRecorder->>AVFoundation: output.stopRecording()
    AVFoundation-->>SegmentedRecorder: didFinishRecordingTo: URL
    SegmentedRecorder-->>CameraViewModel: delegate.didFinishSegment(url)
    CameraViewModel->>CameraViewModel: Generate thumbnail
    CameraViewModel->>CameraViewModel: Create RecordedSegment
    CameraViewModel-->>ContentView: segments.append(segment)
    ContentView-->>User: Show segment thumbnail
    
    Note over User,PhotosApp: User records more segments...
    
    User->>ContentView: Tap Next Button
    ContentView->>CameraViewModel: nextAction()
    CameraViewModel->>CameraViewModel: concatenateAndSaveSegments()
    CameraViewModel->>CameraViewModel: Create AVMutableComposition
    CameraViewModel->>CameraViewModel: Insert all segment time ranges
    CameraViewModel->>CameraViewModel: Apply filter (if selected)
    CameraViewModel->>CameraViewModel: exportComposition()
    CameraViewModel->>FileSystem: Export to temp file
    CameraViewModel->>PhotosApp: PHPhotoLibrary.performChanges
    PhotosApp-->>CameraViewModel: Success
    CameraViewModel->>FileSystem: Remove temp files
    CameraViewModel-->>ContentView: segments.removeAll()
    ContentView-->>User: Segments cleared
Loading

Fluxo de Inicialização

sequenceDiagram
    participant ContentView
    participant CameraViewModel
    participant Controller as CaptureSessionController
    participant System as AVCaptureDevice
    
    ContentView->>CameraViewModel: .onAppear()
    CameraViewModel->>CameraViewModel: requestPermissionsAndConfigure()
    CameraViewModel->>Controller: requestPermissions()
    Controller->>System: AVCaptureDevice.requestAccess(.video)
    Controller->>System: AVCaptureDevice.requestAccess(.audio)
    System-->>Controller: granted = true
    Controller-->>CameraViewModel: completion(granted: true)
    CameraViewModel->>CameraViewModel: isAuthorized = true
    CameraViewModel->>Controller: configureSession(fps: .fps60)
    Controller->>Controller: findBestCamera(for: .front)
    Controller->>System: AVCaptureDevice.DiscoverySession
    System-->>Controller: devices: [AVCaptureDevice]
    Controller->>Controller: Create videoDeviceInput
    Controller->>Controller: Create audioDeviceInput
    Controller->>Controller: setFrameRateLocked(to: 60)
    Controller->>Controller: Add AVCaptureMovieFileOutput
    Controller->>Controller: applyPreferredStabilizationMode()
    Controller->>Controller: setPreferredCodecHEVC(true)
    Controller-->>CameraViewModel: completion(error: nil)
    CameraViewModel->>CameraViewModel: Create SegmentedRecorder
    CameraViewModel->>CameraViewModel: setupOrientationMonitoring()
    CameraViewModel->>Controller: startSession()
    Controller->>System: session.startRunning()
    CameraViewModel-->>ContentView: isSessionRunning = true
    ContentView->>ContentView: Render camera preview
Loading

Fluxo de Filtro de Vídeo

graph TD
    A[User selects filter] --> B[selectedFilter = .mono]
    B --> C[User taps Next]
    C --> D[concatenateAndSaveSegments]
    D --> E{selectedFilter == .none?}
    E -->|Yes| F[Direct AVAssetExportSession]
    E -->|No| G[applyFilter to composition]
    G --> H[Create AVVideoComposition]
    H --> I[Define handler block]
    I --> J[For each frame:]
    J --> K[request.sourceImage]
    K --> L[Apply CIFilter]
    L --> M[CIFilter.photoEffectMono]
    M --> N[outputImage?.cropped]
    N --> O[request.finish with image]
    O --> P[AVAssetExportSession with videoComposition]
    P --> Q[Export filtered video]
    F --> R[saveVideoToPhotos]
    Q --> R
    R --> S[PHPhotoLibrary.performChanges]
    S --> T[Remove temp files]
Loading

Gravação Segmentada

Conceito

O sistema de gravação segmentada permite ao usuário gravar múltiplos takes independentes, visualizar cada um individualmente, deletar takes indesejados e, ao final, concatenar todos em um único vídeo final.

Implementação Técnica

RecordedSegment

struct RecordedSegment: Identifiable, Equatable {
    let id = UUID()
    let url: URL              // Arquivo .mov em NSTemporaryDirectory()
    let thumbnail: UIImage    // Thumbnail gerado do primeiro frame
    let createdAt = Date()
}

Geração de Thumbnail

private func generateThumbnail(for url: URL) -> UIImage? {
    let asset = AVAsset(url: url)
    let generator = AVAssetImageGenerator(asset: asset)
    generator.appliesPreferredTrackTransform = true  // Respeita orientação
    let time = CMTime(seconds: 0.05, preferredTimescale: 600)
    let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
    return UIImage(cgImage: cgImage)
}

Concatenação de Segmentos

Processo detalhado:

  1. Criar Composição
let composition = AVMutableComposition()
let videoTrack = composition.addMutableTrack(withMediaType: .video, ...)
let audioTrack = composition.addMutableTrack(withMediaType: .audio, ...)
  1. Inserir Segmentos Sequencialmente
var currentTime = CMTime.zero
for segmentURL in segmentURLs {
    let asset = AVAsset(url: segmentURL)
    let assetVideoTrack = asset.tracks(withMediaType: .video).first
    
    try videoTrack.insertTimeRange(
        CMTimeRange(start: .zero, duration: asset.duration),
        of: assetVideoTrack,
        at: currentTime
    )
    
    // Preserva orientação original
    videoTrack.preferredTransform = assetVideoTrack.preferredTransform
    
    // Insert audio track...
    
    currentTime = CMTimeAdd(currentTime, asset.duration)
}
  1. Exportar com Filtro (Opcional)
let exporter = AVAssetExportSession(asset: composition, presetName: .highestQuality)
exporter.outputURL = outputURL
exporter.outputFileType = .mov

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
}

exporter.exportAsynchronously { ... }
  1. Salvar em Photos
PHPhotoLibrary.shared().performChanges({
    let req = PHAssetCreationRequest.forAsset()
    req.addResource(with: .video, fileURL: outputURL, options: nil)
})
  1. 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: outputURL)
// Limpa UI
segments.removeAll()

Sistema de Teleprompter

Arquitetura do Sistema

O teleprompter é um dos componentes mais complexos da aplicação, envolvendo sincronização precisa entre SwiftUI e UIKit, gerenciamento de scroll programático e interações gestuais simultâneas.

Componentes

1. TeleprompterOverlay (SwiftUI)

Componente raiz que compõe todos os subcomponentes e gerencia o layout geral.

Estados Gerenciados

@Binding var text: String              // Texto do roteiro
@Binding var speed: Double             // Velocidade de scroll
@Binding var fontSize: CGFloat         // Tamanho da fonte
@Binding var isRecording: Bool         // Estado de gravação
@StateObject var viewModel             // ViewModel interno
@State var showsControls: Bool         // Exibição dos sliders

Layout Hierarchy

RoundedRectangle (background + material)
├── TeleprompterTextView (viewport de scroll)
│   └── LinearGradient (fade bottom)
├── PlayPauseButton (top-right)
├── BottomSlidersBar (center-bottom)
│   ├── CompactSliders (se showsControls)
│   │   ├── Font size slider
│   │   └── Speed slider
│   └── ControlVisibilityButton
├── MoveHandle (bottom-left)
└── ResizeHandle (bottom-right)

2. TeleprompterViewModel

Gerencia todo o estado e lógica do teleprompter.

Scroll Timer

private var scrollTimer: Timer?
private var lastTickTime: Date

func startScrolling(speed: Double, viewportHeight: CGFloat) {
    scrollTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { _ in
        let deltaTime = Date().timeIntervalSince(lastTickTime)
        contentOffset += speed * deltaTime
        
        if contentOffset >= maxOffset {
            if pauseAtEnd {
                contentOffset = maxOffset
                scrollTimer?.invalidate()
                isPlaying = false
            } else {
                contentOffset = 0  // Loop
            }
        }
    }
}

Cálculo de Content Height

Espelha exatamente a tipografia do UITextView:

private func calculateContentHeight(text: String, fontSize: CGFloat, width: CGFloat) -> CGFloat {
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineSpacing = 4
    
    let attributes: [NSAttributedString.Key: Any] = [
        .font: UIFont.systemFont(ofSize: fontSize, weight: .semibold),
        .paragraphStyle: paragraphStyle
    ]
    
    let targetWidth = width - TeleprompterConfig.contentPadding
    let boundingRect = (text as NSString).boundingRect(
        with: CGSize(width: targetWidth, height: .greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        attributes: attributes,
        context: nil
    )
    
    return ceil(boundingRect.height + verticalPadding)
}

Debouncing de Interações

Durante drag/resize, postpone cálculos pesados:

func scheduleContentHeightUpdate(...) {
    if isInteracting {
        scheduledUpdate?.cancel()
        let work = DispatchWorkItem { [weak self] in
            self?.updateContentHeight(...)
        }
        scheduledUpdate = work
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work)
    } else {
        updateContentHeight(...)
    }
}

3. TeleprompterTextView (UIViewRepresentable)

Bridge para UITextView que permite controle total do scroll.

Coordinator Pattern

class Coordinator: NSObject, UITextViewDelegate {
    var isProgrammaticScroll: Bool = false
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard !isProgrammaticScroll else { return }
        let y = max(0, scrollView.contentOffset.y)
        contentOffset.wrappedValue = y
    }
}

Sincronização Bidirecional

SwiftUI → UIKit:

func updateUIView(_ tv: UITextView, context: Context) {
    let currentY = tv.contentOffset.y
    if abs(currentY - contentOffset) > 0.5 {
        context.coordinator.isProgrammaticScroll = true
        tv.setContentOffset(CGPoint(x: 0, y: contentOffset), animated: false)
        context.coordinator.isProgrammaticScroll = false
    }
}

UIKit → SwiftUI:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard !isProgrammaticScroll else { return }
    contentOffset.wrappedValue = scrollView.contentOffset.y
}

Interações Gestuais

MoveHandle (Drag)

.gesture(
    DragGesture(minimumDistance: 0)
        .onChanged { value in
            viewModel.updateOverlayPosition(translation: value.translation)
        }
        .onEnded { _ in
            viewModel.finalizeOverlayPosition(parentSize: parentSize)
        }
)

Implementação:

func updateOverlayPosition(translation: CGSize) {
    beginInteraction()
    overlayOffset = CGSize(
        width: initialDragOffset.width + translation.width,
        height: initialDragOffset.height + translation.height
    )
}

func finalizeOverlayPosition(parentSize: CGSize) {
    // Clamp to screen bounds with margin
    let margin: CGFloat = 24
    overlayOffset.width = max(-(parentSize.width - margin), 
                              min(parentSize.width - margin, overlayOffset.width))
    overlayOffset.height = max(-(parentSize.height - margin),
                               min(parentSize.height - margin, overlayOffset.height))
    initialDragOffset = overlayOffset
    endInteraction()
}

ResizeHandle (Drag Diagonal)

func resizeOverlay(translation: CGSize, parentSize: CGSize) {
    beginInteraction()
    var newWidth = initialResizeSize.width + translation.width
    var newHeight = initialResizeSize.height + translation.height
    
    newWidth = max(TeleprompterConfig.minOverlayWidth, min(parentSize.width, newWidth))
    newHeight = max(TeleprompterConfig.minOverlayHeight, min(parentSize.height, newHeight))
    
    overlaySize = CGSize(width: newWidth, height: newHeight)
}

Sincronização com Gravação

.onChange(of: isRecording) { newValue in
    let padding = newValue ? TeleprompterConfig.compactViewportPadding 
                            : TeleprompterConfig.viewportPadding
    let viewportHeight = max(viewModel.overlaySize.height - padding, 80)
    viewModel.handleRecordingStateChange(isRecording: newValue, speed: speed, viewportHeight: viewportHeight)
}

Quando gravação inicia:

  1. Oculta controles (sliders)
  2. Reduz padding do viewport (mais espaço para texto)
  3. Inicia scroll automático
  4. Desabilita interação manual com o texto

Editor Fullscreen

Sheet modal para edição de texto:

.sheet(isPresented: $viewModel.isEditorPresented) {
    TeleprompterEditorSheet(text: $text, fontSize: $fontSize)
}

TeleprompterEditorSheet

  • TextEditor nativo do SwiftUI
  • Slider de font size na safeAreaInset(edge: .bottom)
  • Auto-focus no TextEditor via @FocusState
  • Placeholder manual (TextEditor não tem placeholder nativo)
ZStack(alignment: .topLeading) {
    if text.isEmpty {
        Text("Adicione seu roteiro aqui...")
            .foregroundColor(.white.opacity(0.35))
    }
    TextEditor(text: $text)
        .focused($isFocused)
}

Sistema de Câmera

Arquitetura AVFoundation

┌──────────────────────────────────────────────────────┐
│                AVCaptureSession                      │
│  ┌────────────────────────────────────────────────┐ │
│  │              Inputs                            │ │
│  │  ┌──────────────────┐  ┌───────────────────┐  │ │
│  │  │AVCaptureDeviceInput│AVCaptureDeviceInput│  │ │
│  │  │  (Video Device)  │  │  (Audio Device)   │  │ │
│  │  └──────────────────┘  └───────────────────┘  │ │
│  └────────────────────────────────────────────────┘ │
│                         │                           │
│                         ▼                           │
│  ┌────────────────────────────────────────────────┐ │
│  │               Processing                       │ │
│  │  Format conversion, stabilization, encoding    │ │
│  └────────────────────────────────────────────────┘ │
│                         │                           │
│                         ▼                           │
│  ┌────────────────────────────────────────────────┐ │
│  │               Outputs                          │ │
│  │  ┌──────────────────┐  ┌───────────────────┐  │ │
│  │  │AVCaptureMovieFile│  │AVCaptureVideoData │  │ │
│  │  │     Output       │  │   (não usado)     │  │ │
│  │  └──────────────────┘  └───────────────────┘  │ │
│  └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
          │                            │
          ▼                            ▼
┌────────────────────┐      ┌────────────────────────┐
│AVCaptureVideoPreview│      │   File System          │
│       Layer        │      │   segment_xxx.mov      │
└────────────────────┘      └────────────────────────┘

Threading Model

O CaptureSessionController usa uma queue serial dedicada para todas as operações:

private let sessionQueue = DispatchQueue(label: "camera.session.queue")

Regras de Threading

  1. Session Configuration: Sempre na sessionQueue
sessionQueue.async {
    session.beginConfiguration()
    // ... modifications ...
    session.commitConfiguration()
}
  1. Device Configuration: Lock necessário na sessionQueue
sessionQueue.async {
    try device.lockForConfiguration()
    device.videoZoomFactor = 2.0
    device.unlockForConfiguration()
}
  1. Estado UI: Sempre na main thread
DispatchQueue.main.async {
    self.isSessionRunning = true
}

Seleção de Dispositivo

Hierarquia de preferência:

Back Camera

  1. .builtInTripleCamera (iPhone 11 Pro+, 13 Pro+, 14 Pro+)
  2. .builtInDualWideCamera (iPhone 11, 12, 13)
  3. .builtInDualCamera (iPhone 7 Plus - X)
  4. .builtInWideAngleCamera (fallback universal)

Front Camera

  1. .builtInTrueDepthCamera (iPhone X+, iPad Pro 2018+)
  2. .builtInWideAngleCamera (fallback universal)
private func findBestCamera(for position: AVCaptureDevice.Position) throws -> AVCaptureDevice {
    let types: [AVCaptureDevice.DeviceType] = position == .back 
        ? [.builtInTripleCamera, .builtInDualWideCamera, .builtInDualCamera, .builtInWideAngleCamera]
        : [.builtInTrueDepthCamera, .builtInWideAngleCamera]
    
    let discovery = AVCaptureDevice.DiscoverySession(
        deviceTypes: types,
        mediaType: .video,
        position: position
    )
    
    guard let device = discovery.devices.first else {
        throw NSError(...)
    }
    return device
}

Virtual Device vs Physical Device

Virtual Device (Triple/Dual Camera)

  • Sistema gerencia troca automática entre lentes
  • videoZoomFactor 0.5x-2.0x+ aciona transições suaves
  • Ultra-wide (0.5x), Wide (1x), Telephoto (2x) via zoom digital
  • Melhor opção para UX sem costura

Physical Device (Single Lens)

  • Necessário trocar AVCaptureDeviceInput manualmente
  • Usado no jumpToHalfX() em back camera sem virtual device
  • Requer session.beginConfiguration() / commitConfiguration()

Formatos e Frame Rates

Algoritmo de Seleção de Formato

var bestFormat: AVCaptureDevice.Format?
var bestDimensions = CMVideoDimensions(width: 0, height: 0)

for format in device.formats {
    guard let range = format.videoSupportedFrameRateRanges.first(
        where: { $0.maxFrameRate >= desiredFPS }
    ) else { continue }
    
    let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
    let isBetter = (dims.width * dims.height) > (bestDimensions.width * bestDimensions.height)
    
    if isBetter {
        bestFormat = format
        bestDimensions = dims
    }
}

device.activeFormat = bestFormat

Prioriza:

  1. Suporte ao frame rate desejado
  2. Maior resolução disponível (width × height)

Configuração de Frame Duration

let clampedFPS = min(range.maxFrameRate, desiredFPS)
let duration = CMTimeMake(value: 1, timescale: Int32(clampedFPS))
device.activeVideoMinFrameDuration = duration
device.activeVideoMaxFrameDuration = duration

Define duração mínima e máxima iguais para frame rate fixo.

Zoom Implementation

Digital Zoom Range

let minZoom = device.minAvailableVideoZoomFactor  // Tipicamente 1.0 ou 0.5
let maxZoom = device.maxAvailableVideoZoomFactor  // Pode ser 15.0+ em iPhones modernos
let clamped = max(minZoom, min(6.0, factor))      // Limite prático de 6.0x

Escolha Técnica: Limite de 6.0x

  • Acima de 6x, a qualidade degrada significativamente (zoom digital puro)
  • Em devices com telephoto, transições acontecem em 2x automaticamente

Ramping vs Immediate

Ramping (animated = true)

device.ramp(toVideoZoomFactor: target, withRate: rate)
  • rate: velocidade de transição (pts/segundo)
  • Transição suave sem saltos visuais
  • Usado em gestos de pinch e quick zoom buttons

Immediate (animated = false)

device.videoZoomFactor = target
  • Aplicação instantânea
  • Usado após troca de câmera ou reset

Cancelamento de Ramp

func cancelZoomRamp() {
    if device.isRampingVideoZoom {
        device.cancelVideoZoomRamp()
    }
}
  • Chamado no final de gesture de pinch
  • Para ramping em progresso antes de novo ajuste

Foco e Exposição

Point of Interest

func focusAndExpose(at devicePoint: CGPoint) {
    // devicePoint: coordenadas (0.0-1.0, 0.0-1.0) relativas ao sensor
    
    if device.isFocusPointOfInterestSupported {
        device.focusPointOfInterest = devicePoint
        device.focusMode = .continuousAutoFocus
    }
    
    if device.isExposurePointOfInterestSupported {
        device.exposurePointOfInterest = devicePoint
        device.exposureMode = .continuousAutoExposure
    }
    
    device.isSubjectAreaChangeMonitoringEnabled = true
}

Conversão de Coordenadas

Na CameraPreviewView:

@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
    let location = gesture.location(in: self)
    let devicePoint = videoPreviewLayer.captureDevicePointConverted(fromLayerPoint: location)
    onTapToFocus?(devicePoint)
}

captureDevicePointConverted(fromLayerPoint:) converte coordenadas de tela (pontos) para coordenadas do sensor (0.0-1.0).

Focus Modes

  • .continuousAutoFocus: Ajuste automático contínuo (usado após tap)
  • .autoFocus: Focus único (não usado nesta app)
  • .locked: Focus travado (não usado)

Torch (Flash)

Back Camera

Usa torch de hardware do AVCaptureDevice:

func setTorchEnabled(_ enabled: Bool) {
    guard device.hasTorch else { return }
    
    if enabled {
        let level = min(max(0.0, AVCaptureDevice.maxAvailableTorchLevel), 1.0)
        try device.setTorchModeOn(level: level)
    } else {
        device.torchMode = .off
    }
}

maxAvailableTorchLevel: Valor entre 0.0-1.0 indicando intensidade máxima segura sem sobreaquecer

Front Camera

Simula torch usando brilho da tela:

private func setScreenTorchEnabled(_ enabled: Bool) {
    if enabled {
        savedScreenBrightness = UIScreen.main.brightness
        UIScreen.main.brightness = 1.0
    } else {
        if let original = savedScreenBrightness {
            UIScreen.main.brightness = original
        }
    }
}

Escolha Técnica: Front camera não tem flash hardware na maioria dos devices iOS. Screen brightness é solução padrão do mercado (usado por câmeras nativas de várias apps).

Orientação

Device Orientation → Video Orientation

extension AVCaptureVideoOrientation {
    init?(deviceOrientation: UIDeviceOrientation) {
        switch deviceOrientation {
        case .portrait: self = .portrait
        case .portraitUpsideDown: self = .portraitUpsideDown
        case .landscapeLeft: self = .landscapeRight   // Invertido!
        case .landscapeRight: self = .landscapeLeft   // Invertido!
        default: return nil
        }
    }
}

Nota: landscapeLeft do device corresponde a landscapeRight da câmera devido ao offset de 90° do sensor.

Monitoramento de Orientação

No CameraViewModel:

private func setupOrientationMonitoring() {
    UIDevice.current.beginGeneratingDeviceOrientationNotifications()
    
    orientationObserver = NotificationCenter.default.addObserver(
        forName: UIDevice.orientationDidChangeNotification,
        object: nil,
        queue: .main
    ) { [weak self] _ in
        guard let self, let videoOrientation = AVCaptureVideoOrientation(
            deviceOrientation: UIDevice.current.orientation
        ) else { return }
        
        self.controller.setVideoOrientation(videoOrientation)
        self.recorder?.updateOrientation(from: UIDevice.current.orientation)
    }
}

Atualiza:

  1. AVCaptureConnection.videoOrientation no movieFileOutput
  2. SegmentedRecorder.orientation para próximas gravações

Preferred Transform

Durante concatenação, preserva orientação:

videoTrack.preferredTransform = assetVideoTrack.preferredTransform

preferredTransform: Matriz de transformação afim que indica como rotacionar o vídeo durante playback.

Estabilização

func applyPreferredStabilizationMode() {
    for connection in movieFileOutput.connections {
        if connection.isVideoStabilizationSupported {
            connection.preferredVideoStabilizationMode = .cinematic
        }
    }
}

Modos de Estabilização

  • .off: Sem estabilização
  • .standard: Estabilização básica (crop pequeno)
  • .cinematic: Estabilização agressiva (crop maior, suavização máxima)
  • .auto: Sistema decide

Escolha Técnica: .cinematic oferece melhor resultado para vídeos de roteiro/apresentação, onde movimento suave é prioritário sobre campo de visão máximo.

Mirroring

private func applyMirroringForCurrentPosition() {
    let shouldMirror = (currentPosition == .front)
    for connection in movieFileOutput.connections {
        if connection.isVideoMirroringSupported {
            connection.automaticallyAdjustsVideoMirroring = false
            connection.isVideoMirrored = shouldMirror
        }
    }
}

Comportamento:

  • Front camera: Espelhado (match preview, UX padrão iOS)
  • Back camera: Normal (não espelhado)

Codec Selection

func setPreferredCodecHEVC(_ enabled: Bool) {
    guard let connection = movieFileOutput.connection(with: .video) else { return }
    let available = movieFileOutput.availableVideoCodecTypes
    
    if enabled, available.contains(.hevc) {
        movieFileOutput.setOutputSettings([AVVideoCodecKey: AVVideoCodecType.hevc], for: connection)
    } else if available.contains(.h264) {
        movieFileOutput.setOutputSettings([AVVideoCodecKey: AVVideoCodecType.h264], for: connection)
    }
}

HEVC (H.265)

  • Melhor compressão (~50% menor que H.264)
  • Suportado desde iPhone 7 / iOS 11
  • Padrão do sistema em devices modernos

H.264

  • Fallback para compatibilidade
  • Menor compressão, maior compatibilidade

Filtros de Vídeo

Arquitetura de Filtros

A aplicação não aplica filtros em tempo real durante a captura (performance). Filtros são aplicados durante a exportação final usando AVVideoComposition.

Filtros Disponíveis

enum VideoFilter: String, CaseIterable {
    case none
    case mono
    
    var displayName: String {
        switch self {
        case .none: return "Nenhum"
        case .mono: return "Suavizar"
        }
    }
}

Nota: "Suavizar" é nome de UI para photoEffectMono, um filtro monocromático com contraste suavizado.

Implementação com Core Image

Pipeline de Aplicação

let videoComposition = AVVideoComposition(asset: asset) { request in
    let src = request.sourceImage.clampedToExtent()
    let output: CIImage?
    
    switch filterType {
    case .none:
        output = src
    case .mono:
        let f = CIFilter.photoEffectMono()
        f.inputImage = src
        output = f.outputImage
    }
    
    if let img = output?.cropped(to: request.sourceImage.extent) {
        request.finish(with: img, context: nil)
    } else {
        request.finish(with: NSError(...))
    }
}

Método .clampedToExtent()

let src = request.sourceImage.clampedToExtent()

Propósito: Estende a imagem infinitamente em todas as direções repetindo pixels de borda. Necessário porque alguns filtros Core Image podem amostrar fora dos bounds originais.

Cropping

output?.cropped(to: request.sourceImage.extent)

Após filtro, crop de volta ao extent original para evitar padding indesejado.

Integração com Export

let exporter = AVAssetExportSession(asset: asset, presetName: .highestQuality)
exporter.videoComposition = videoComposition
exporter.outputFileType = .mov
exporter.exportAsynchronously { ... }

AVAssetExportSession processa frame-by-frame, aplicando o block de AVVideoComposition a cada frame.

Performance Considerations

Por que não aplicar em tempo real?

  1. Core Image rendering adiciona latência significativa
  2. AVCaptureMovieFileOutput não suporta composição inline durante recording
  3. Export assíncrono permite UI não bloqueante com progress (não implementado mas possível)

Alternativa para tempo real: Usar AVCaptureVideoDataOutput com callback de buffer, aplicar filtro, usar AVAssetWriter. Muito mais complexo e consome bateria.

Extensibilidade

Para adicionar novo filtro:

  1. Adicionar case em VideoFilter enum
  2. Adicionar case no switch de applyFilter
  3. Instanciar CIFilter apropriado

Exemplo:

case .sepia:
    let f = CIFilter.sepiaTone()
    f.intensity = 0.8
    f.inputImage = src
    output = f.outputImage

Escolhas Técnicas

1. MVVM com Combine

Razão: Separação clara de responsabilidades. ViewModels testáveis sem dependência de SwiftUI. @Published properties fornecem reatividade declarativa sem boilerplate.

Alternativas Consideradas:

  • MVC puro: Muito acoplamento entre View e Model
  • VIPER: Over-engineering para app de escopo limitado

2. SwiftUI para UI

Razão: Declaratividade, hot reload, bindings bidirecionais nativos, animation system robusto.

Ponte UIKit: Necessária para AVCaptureVideoPreviewLayer e UITextView (scroll preciso). Implementado via UIViewRepresentable.

3. Serial DispatchQueue para Session

Razão: AVCaptureSession não é thread-safe. Queue serial garante operações sequenciais sem race conditions.

private let sessionQueue = DispatchQueue(label: "camera.session.queue")

Todas as operações de configuração executam nesta queue.

4. Gravação Segmentada vs Única

Razão: Permite múltiplos takes sem perder trabalho anterior. Facilita iteração criativa (comum em roteiros).

Trade-off: Maior uso de disco temporário, complexidade de concatenação.

5. Torch de Tela para Front Camera

Razão: Front camera não tem flash hardware. Aumentar brilho da tela é método padrão da indústria.

Implementação: Salva brilho original, restaura ao desativar ou trocar de câmera.

6. HEVC como Codec Padrão

Razão: 50% menor tamanho de arquivo com qualidade equivalente. Suportado em todos os devices target (iOS 18.5+).

Fallback: H.264 se HEVC não disponível (teoricamente impossível no target, mas defensivo).

7. Filtros na Exportação

Razão: AVCaptureMovieFileOutput não suporta composição inline. Export assíncrono mantém UI responsiva.

Trade-off: Usuário não vê preview exato do filtro (apenas hint visual).

8. Frame Rate Padrão 60fps

Razão: 60fps é padrão para conteúdo smooth (tech reviews, apresentações). 30fps disponível para economia de espaço.

Seleção de Formato: Algoritmo prefere maior resolução disponível que suporte o frame rate.

9. Limite de Zoom 6.0x

Razão: Acima de 6x, interpolação digital degrada qualidade visivelmente. Evita UX ruim.

let maxZoom = min(device.maxAvailableVideoZoomFactor, 6.0)

10. UITextView para Teleprompter

Razão: SwiftUI Text não oferece scroll programático preciso. UITextView permite controle total de contentOffset.

Bridge: UIViewRepresentable com Coordinator para sincronização bidirecional.

11. Debouncing de Medidas durante Interação

Razão: Cálculo de boundingRect é caro. Durante drag/resize, debounce evita milhares de recálculos.

DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work)

12. Orientação Automática

Razão: Vídeos devem manter orientação correta automaticamente. Monitoramento via UIDevice.orientationDidChangeNotification.

Aplicação: AVCaptureConnection.videoOrientation + AVMutableVideoCompositionLayerInstruction.setTransform

13. GlassCompat Buttons

Razão: Visual moderno e profissional com material translúcido. .ultraThinMaterial + overlays sutis.

Sem Dependências: Implementação custom evita dependências de terceiros.


Setup e Requisitos

Requisitos de Sistema

  • Xcode: 16.4 ou superior
  • iOS Deployment Target: 18.5 ou superior
  • Swift: 5.9+
  • Device: iPhone/iPad físico (câmera não funciona em simulator)

Permissões Necessárias

No Info.plist (ou Camera.entitlements):

<key>NSCameraUsageDescription</key>
<string>Necessário para gravar vídeos</string>

<key>NSMicrophoneUsageDescription</key>
<string>Necessário para gravar áudio</string>

<key>NSPhotoLibraryAddUsageDescription</key>
<string>Necessário para salvar vídeos gravados</string>

Build Configuration

No project.pbxproj:

PRODUCT_BUNDLE_IDENTIFIER = com.pedro.Camera
DEVELOPMENT_TEAM = <seu_team_id>
CODE_SIGN_STYLE = Automatic
TARGETED_DEVICE_FAMILY = "1,2"  (iPhone e iPad)

Instalação

git clone https://github.com/Pedroodelvalle/camera-swift.git
cd camera-swift
open Camera.xcodeproj
  1. Conecte device físico via USB
  2. Selecione device no Xcode
  3. Assine com Apple ID (Xcode → Preferences → Accounts)
  4. Build e run (⌘+R)

Troubleshooting

"Privacy-sensitive data" error

  • Verificar presença das keys NSCameraUsageDescription, NSMicrophoneUsageDescription no Info.plist

Torch não funciona

  • Device físico necessário
  • Back camera: verificar device.hasTorch
  • Front camera: verificar UIScreen.main.brightness (sempre disponível)

Vídeos salvos com orientação errada

  • Verificar preferredTransform sendo preservado durante concatenação
  • Verificar connection.videoOrientation sendo atualizado

Performance ruim do teleprompter

  • Verificar se isInteracting está desabilitando animações implícitas
  • Verificar se debouncing está ativo durante resize

Crash ao trocar câmera

  • Verificar operações de session em sessionQueue
  • Verificar beginConfiguration / commitConfiguration pairs

Arquitetura de Arquivos

Camera/
├── CameraApp.swift                   # Entry point, @main
├── ContentView.swift                 # View principal, orquestração UI
├── CameraViewModel.swift             # Estado central, lógica de negócio
├── CaptureSessionController.swift    # Gerenciamento AVCaptureSession
├── SegmentedRecorder.swift           # Gravação de múltiplos takes
├── CameraPreviewView.swift           # UIKit bridge para preview layer
├── TeleprompterOverlay.swift         # UI do teleprompter
├── TeleprompterTextView.swift        # UITextView bridge para scroll
├── TeleprompterViewModel.swift       # Lógica de scroll e interação
├── GlassCompat.swift                 # Componentes de UI reutilizáveis
├── Assets.xcassets/                  # Recursos visuais
└── Camera.entitlements               # Permissões e capabilities

Dependências entre Módulos

CameraApp
    └── ContentView
            ├── CameraViewModel
            │       ├── CaptureSessionController
            │       └── SegmentedRecorder
            ├── CameraPreviewView
            └── TeleprompterOverlay
                    ├── TeleprompterViewModel
                    └── TeleprompterTextView

Todos os componentes usam GlassCompat para UI styling.


Performance e Otimizações

1. Session Configuration

Configuração inicial executada uma vez em background queue:

sessionQueue.async {
    // Configuração pesada aqui
}

2. Thumbnail Generation

Executado em background:

DispatchQueue.global(qos: .userInitiated).async {
    let thumbnail = generateThumbnail(for: url)
    DispatchQueue.main.async {
        self.segments.append(segment)
    }
}

3. Teleprompter Measurements

Cached com signature:

let signature = "\(text.hashValue)|\(fontSize)|\(Int(width))"
guard signature != lastContentSignature else { return }

4. Video Export

Assíncrono com cleanup:

exporter.exportAsynchronously {
    // Main thread callback
    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
        try? FileManager.default.removeItem(at: tempURL)
    }
}

5. Scroll Timer

60fps timer para smoothness:

Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true)

6. Gesture Debouncing

Evita recalculações durante interação:

.animation(viewModel.isInteracting ? .none : .default, value: viewModel.overlayOffset)

Limitações Conhecidas

1. Preview de Filtro

Filtros não são aplicados em tempo real no preview. Apenas hint visual.

Razão: AVCaptureMovieFileOutput não suporta composição inline.

2. Máximo de Segmentos

Sem limite hard-coded, mas cada segmento ocupa espaço em disco temporário.

Mitigação: Arquivos temporários são limpos após concatenação.

3. Zoom Máximo

Limitado a 6.0x mesmo que device suporte mais.

Razão: Qualidade acima de 6x é ruim.

4. Orientação do Teleprompter

Teleprompter não rotaciona automaticamente com device.

Razão: Overlay é posicionado manualmente. Auto-rotate quebraria posicionamento.

5. Background Recording

Gravação para se app for para background.

Razão: iOS suspende AVCaptureSession em background por padrão.

6. Picture-in-Picture

Não suportado durante gravação.

Razão: Feature não implementada (complexidade adicional).


Roadmap Futuro (Sugestões)

Features

  1. Filtros em Tempo Real: Usar AVCaptureVideoDataOutput + Metal para preview de filtro
  2. Picture-in-Picture: Continuar visualizando preview ao sair do app
  3. Mais Filtros: Sepia, Noir, Chrome, Fade, Transfer
  4. Export Progressivo: UI de progress durante concatenação/export
  5. Cloud Backup: Upload automático de vídeos finalizados
  6. Gesture Recording: Marcar pontos-chave durante gravação

Otimizações

  1. Background Upload: Continuar upload em background
  2. Metal Rendering: Acelerar aplicação de filtros com GPU
  3. Adaptive Quality: Ajustar resolução baseado em espaço disponível
  4. Segment Preloading: Pré-carregar próximo segmento para preview mais rápido

UX

  1. Onboarding: Tutorial ao primeiro uso
  2. Gesture Hints: Dicas visuais de pinch/tap/double-tap
  3. Undo/Redo: Stack de ações reversíveis
  4. Project Management: Salvar projetos com múltiplos vídeos

Referências Técnicas

Apple Documentation

WWDC Sessions

  • WWDC 2023: What's new in Camera Capture
  • WWDC 2021: Discover ARKit 5
  • WWDC 2020: Edit and play back HDR video with AVFoundation

📚 Documentação Online

Para uma experiência de leitura melhor, acesse a documentação completa no GitHub Pages:

🏠 Homepage: https://conty-app.github.io/camera-swift/

Links Rápidos

Diferenciais da Documentação Online

Dark Mode Minimalista - Design focado em leitura
Navegação Intuitiva - Busca rápida por seções
Código Colorido - Syntax highlighting profissional
Responsivo - Perfeito em mobile e desktop
Estrutura Organizada - Conteúdo dividido logicamente


Contato e Manutenção

Desenvolvedor: Pedro Deboni Del Valle
Time: Conty
Última Atualização: 2025-10-12 (v0.2.0)

Para questões sobre o código ou arquitetura, contatar o desenvolvedor responsável pelo projeto.


Nota: Esta documentação é atualizada manualmente. Sempre verificar o código-fonte para comportamento definitivo em caso de discrepância.