📚 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 🚀
- Visão Geral
- Arquitetura
- Componentes Principais
- Fluxo de Dados
- Gravação Segmentada
- Sistema de Teleprompter
- Sistema de Câmera
- Filtros de Vídeo
- Escolhas Técnicas
- Setup e Requisitos
- 📚 Documentação Online
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.
Interface com teleprompter ativo | Interface limpa sem teleprompter
Seleção de filtros ao vivo | Visualização de segmentos gravados
- 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.cinematicquando 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
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 │ └─────────────────────────────────────────────────────────┘ View (SwiftUI)
- Renderização da interface
- Captura de gestos do usuário
- Binding bidirecional com ViewModels via
@Published
ViewModel
- Estado da aplicação (
@Publishedproperties) - 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
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.
@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 fonteInicializaçã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
SegmentedRecordere 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
isRecordingpara controle de UI
Processamento de Segmentos
func nextAction()Fluxo completo de concatenação e salvamento:
- Cria
AVMutableCompositionvazio - Adiciona tracks de vídeo e áudio
- Para cada segmento, insere
CMTimeRangena composição - Preserva
preferredTransformpara orientação correta - Aplica filtro se selecionado (via
AVVideoComposition) - Exporta com
AVAssetExportSession(preset:highestQuality) - Salva no Photos via
PHPhotoLibrary - Remove arquivos temporários
- Limpa array
segments
SegmentedRecorderDelegate
func recorder(_ recorder: SegmentedRecorder, didFinishSegment url: URL)- Gera thumbnail do vídeo com
AVAssetImageGenerator(frame em 0.05s) - Cria
RecordedSegmentcom URL e thumbnail - Adiciona ao array
segmentsna main thread
func recorder(_ recorder: SegmentedRecorder, didFailWith error: Error)- Loga erro detalhado
- Reseta
isRecording = false
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.
CaptureSessionController ├── session: AVCaptureSession ├── sessionQueue: DispatchQueue // Queue serial para thread-safety ├── videoDevice: AVCaptureDevice? // Dispositivo de câmera atual ├── videoDeviceInput: AVCaptureDeviceInput? ├── audioDeviceInput: AVCaptureDeviceInput? └── movieFileOutput: AVCaptureMovieFileOutput? Configuração de Session
func configureSession( desiredFrameRate: DesiredFrameRate = .fps60, position: AVCaptureDevice.Position = .front, completion: ((Error?) -> Void)? = nil )Executa na sessionQueue:
session.beginConfiguration()- Define preset
.high - Encontra melhor câmera via
findBestCamera(for:):- Back: Prefere
.builtInTripleCamera>.builtInDualWideCamera>.builtInDualCamera>.builtInWideAngleCamera - Front: Prefere
.builtInTrueDepthCamera>.builtInWideAngleCamera
- Back: Prefere
- Remove inputs existentes
- Adiciona video input e audio input
- Configura frame rate via
setFrameRateLocked(to:) - Adiciona
AVCaptureMovieFileOutputse necessário - Aplica estabilização cinemática
- Configura mirroring (front = espelhado, back = normal)
- Define codec HEVC como preferido
- Força zoom 1.0 inicial
session.commitConfiguration()
Seleção de Formato e Frame Rate
private func setFrameRateLocked(to fps: Int) throwsAlgoritmo de seleção de formato:
- Filtra
device.formatspara encontrar formatos que suportam o FPS desejado - Para cada formato, verifica
videoSupportedFrameRateRanges - Seleciona formato com maior resolução (compara
width * height) - Define
device.activeFormat - Configura
activeVideoMinFrameDurationeactiveVideoMaxFrameDurationpara 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: usaramp(toVideoZoomFactor:withRate:)para transição suave - Animated =
false: definevideoZoomFactordiretamente
Foco e Exposição por Toque
func focusAndExpose(at devicePoint: CGPoint)- Recebe
devicePointconvertido da UI (0.0-1.0 em x,y) - Configura
focusPointOfInterestefocusMode = .continuousAutoFocus - Configura
exposurePointOfInteresteexposureMode = .continuousAutoExposure - Habilita
isSubjectAreaChangeMonitoringEnabled
Alternância de Câmera
func toggleCameraPosition()Processo:
- Determina próxima posição (front ↔ back)
- Encontra melhor dispositivo para a posição via
findBestCamera(for:) - Troca input via
useDevice(_:)(begin/commit configuration) - Reaplica frame rate configurado
- Reseta zoom para 1.0
- Reaplica estado do torch (se suportado)
- 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 = .cinematicse suportado
Orientação
func setVideoOrientation(_ orientation: AVCaptureVideoOrientation)- Define orientação no
AVCaptureConnectiondomovieFileOutput - Sincroniza mirroring com a posição atual da câmera
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: AnyObject { func recorder(_ recorder: SegmentedRecorder, didFinishSegment url: URL) func recorder(_ recorder: SegmentedRecorder, didFailWith error: Error) }┌─────────────────────────────────────────────────────────────┐ │ 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 │ └──────────────────────────────────────┘ - Cada segmento é um arquivo
.movindependente emNSTemporaryDirectory() - Nomes de arquivo:
segment_{UUID}.mov - Orientação é atualizada via
updateOrientation(from:)quando dispositivo rotaciona - Propriedade
saveToPhotoLibrary: setrue, salva cada segmento individualmente (no nosso caso,false)
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.
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 Responsabilidades
- 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)
- Se
- Cálculo de Content Height
func updateContentHeight(text: String, fontSize: CGFloat, width: CGFloat)- Cria
NSAttributedStringcom mesmas propriedades doUITextView - Usa
boundingRect(with:options:attributes:)para medir altura - Adiciona padding vertical configurado
- Cacheia resultado com signature
{text.hashValue}|{fontSize}|{Int(width)}
- Interações de Drag/Resize
func updateOverlayPosition(translation: CGSize) func finalizeOverlayPosition(parentSize: CGSize) func resizeOverlay(translation: CGSize, parentSize: CGSize) func finalizeResize()- Marca
isInteracting = truedurante gestos - Debounce de cálculos pesados (height update/clamp) via
DispatchWorkItem - Finalização aplica constraints (limites de viewport)
- 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)
Bridge entre SwiftUI e UIKit para controle preciso de scroll.
Coordinator
class Coordinator: NSObject, UITextViewDelegate { var isProgrammaticScroll: Bool func scrollViewDidScroll(_ scrollView: UIScrollView) }- Flag
isProgrammaticScrollevita 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 = 0Atualizaçã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 }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)
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çãoArquivo: ContentView.swift
View principal da aplicação. Orquestra todos os componentes visuais e coordena interações do usuário com o CameraViewModel.
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) } 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
FilterMenucom 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 } } }Preview de Segmento
.sheet(item: $previewSegment) { SegmentPlaybackView(segment:onDelete:onClose:) }- Exibe
AVPlayerem 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)private func startCountdown() { countdown = 0 countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in countdown += 1 } }- Formata como
MM:SSviatimeString(from:) - Reseta ao parar gravação
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 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 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] 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.
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() }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) }Processo detalhado:
- Criar Composição
let composition = AVMutableComposition() let videoTrack = composition.addMutableTrack(withMediaType: .video, ...) let audioTrack = composition.addMutableTrack(withMediaType: .audio, ...)- 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) }- 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 { ... }- Salvar em Photos
PHPhotoLibrary.shared().performChanges({ let req = PHAssetCreationRequest.forAsset() req.addResource(with: .video, fileURL: outputURL, options: nil) })- 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()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.
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 slidersLayout 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) 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(...) } }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 }.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() }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) }.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:
- Oculta controles (sliders)
- Reduz padding do viewport (mais espaço para texto)
- Inicia scroll automático
- Desabilita interação manual com o texto
Sheet modal para edição de texto:
.sheet(isPresented: $viewModel.isEditorPresented) { TeleprompterEditorSheet(text: $text, fontSize: $fontSize) }TeleprompterEditorSheet
TextEditornativo do SwiftUI- Slider de font size na
safeAreaInset(edge: .bottom) - Auto-focus no
TextEditorvia@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) }┌──────────────────────────────────────────────────────┐ │ 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 │ └────────────────────┘ └────────────────────────┘ O CaptureSessionController usa uma queue serial dedicada para todas as operações:
private let sessionQueue = DispatchQueue(label: "camera.session.queue")Regras de Threading
- Session Configuration: Sempre na
sessionQueue
sessionQueue.async { session.beginConfiguration() // ... modifications ... session.commitConfiguration() }- Device Configuration: Lock necessário na
sessionQueue
sessionQueue.async { try device.lockForConfiguration() device.videoZoomFactor = 2.0 device.unlockForConfiguration() }- Estado UI: Sempre na main thread
DispatchQueue.main.async { self.isSessionRunning = true }Hierarquia de preferência:
Back Camera
.builtInTripleCamera(iPhone 11 Pro+, 13 Pro+, 14 Pro+).builtInDualWideCamera(iPhone 11, 12, 13).builtInDualCamera(iPhone 7 Plus - X).builtInWideAngleCamera(fallback universal)
Front Camera
.builtInTrueDepthCamera(iPhone X+, iPad Pro 2018+).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 (Triple/Dual Camera)
- Sistema gerencia troca automática entre lentes
videoZoomFactor0.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
AVCaptureDeviceInputmanualmente - Usado no
jumpToHalfX()em back camera sem virtual device - Requer
session.beginConfiguration()/commitConfiguration()
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 = bestFormatPrioriza:
- Suporte ao frame rate desejado
- Maior resolução disponível (width × height)
let clampedFPS = min(range.maxFrameRate, desiredFPS) let duration = CMTimeMake(value: 1, timescale: Int32(clampedFPS)) device.activeVideoMinFrameDuration = duration device.activeVideoMaxFrameDuration = durationDefine duração mínima e máxima iguais para frame rate fixo.
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.0xEscolha 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 (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
func cancelZoomRamp() { if device.isRampingVideoZoom { device.cancelVideoZoomRamp() } }- Chamado no final de gesture de pinch
- Para ramping em progresso antes de novo ajuste
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).
.continuousAutoFocus: Ajuste automático contínuo (usado após tap).autoFocus: Focus único (não usado nesta app).locked: Focus travado (não usado)
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
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).
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.
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:
AVCaptureConnection.videoOrientationnomovieFileOutputSegmentedRecorder.orientationpara próximas gravações
Durante concatenação, preserva orientação:
videoTrack.preferredTransform = assetVideoTrack.preferredTransformpreferredTransform: Matriz de transformação afim que indica como rotacionar o vídeo durante playback.
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.
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)
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
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.
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.
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(...)) } }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.
output?.cropped(to: request.sourceImage.extent)Após filtro, crop de volta ao extent original para evitar padding indesejado.
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.
Por que não aplicar em tempo real?
- Core Image rendering adiciona latência significativa
AVCaptureMovieFileOutputnão suporta composição inline durante recording- 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.
Para adicionar novo filtro:
- Adicionar case em
VideoFilterenum - Adicionar case no switch de
applyFilter - Instanciar
CIFilterapropriado
Exemplo:
case .sepia: let f = CIFilter.sepiaTone() f.intensity = 0.8 f.inputImage = src output = f.outputImageRazã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
Razão: Declaratividade, hot reload, bindings bidirecionais nativos, animation system robusto.
Ponte UIKit: Necessária para AVCaptureVideoPreviewLayer e UITextView (scroll preciso). Implementado via UIViewRepresentable.
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.
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.
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.
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).
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).
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.
Razão: Acima de 6x, interpolação digital degrada qualidade visivelmente. Evita UX ruim.
let maxZoom = min(device.maxAvailableVideoZoomFactor, 6.0)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.
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)Razão: Vídeos devem manter orientação correta automaticamente. Monitoramento via UIDevice.orientationDidChangeNotification.
Aplicação: AVCaptureConnection.videoOrientation + AVMutableVideoCompositionLayerInstruction.setTransform
Razão: Visual moderno e profissional com material translúcido. .ultraThinMaterial + overlays sutis.
Sem Dependências: Implementação custom evita dependências de terceiros.
- 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)
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>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) git clone https://github.com/Pedroodelvalle/camera-swift.git cd camera-swift open Camera.xcodeproj- Conecte device físico via USB
- Selecione device no Xcode
- Assine com Apple ID (Xcode → Preferences → Accounts)
- Build e run (⌘+R)
"Privacy-sensitive data" error
- Verificar presença das keys
NSCameraUsageDescription,NSMicrophoneUsageDescriptionno 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
preferredTransformsendo preservado durante concatenação - Verificar
connection.videoOrientationsendo atualizado
Performance ruim do teleprompter
- Verificar se
isInteractingestá 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/commitConfigurationpairs
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 CameraApp └── ContentView ├── CameraViewModel │ ├── CaptureSessionController │ └── SegmentedRecorder ├── CameraPreviewView └── TeleprompterOverlay ├── TeleprompterViewModel └── TeleprompterTextView Todos os componentes usam GlassCompat para UI styling.
Configuração inicial executada uma vez em background queue:
sessionQueue.async { // Configuração pesada aqui }Executado em background:
DispatchQueue.global(qos: .userInitiated).async { let thumbnail = generateThumbnail(for: url) DispatchQueue.main.async { self.segments.append(segment) } }Cached com signature:
let signature = "\(text.hashValue)|\(fontSize)|\(Int(width))" guard signature != lastContentSignature else { return }Assíncrono com cleanup:
exporter.exportAsynchronously { // Main thread callback DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { try? FileManager.default.removeItem(at: tempURL) } }60fps timer para smoothness:
Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true)Evita recalculações durante interação:
.animation(viewModel.isInteracting ? .none : .default, value: viewModel.overlayOffset)Filtros não são aplicados em tempo real no preview. Apenas hint visual.
Razão: AVCaptureMovieFileOutput não suporta composição inline.
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.
Limitado a 6.0x mesmo que device suporte mais.
Razão: Qualidade acima de 6x é ruim.
Teleprompter não rotaciona automaticamente com device.
Razão: Overlay é posicionado manualmente. Auto-rotate quebraria posicionamento.
Gravação para se app for para background.
Razão: iOS suspende AVCaptureSession em background por padrão.
Não suportado durante gravação.
Razão: Feature não implementada (complexidade adicional).
- Filtros em Tempo Real: Usar
AVCaptureVideoDataOutput+ Metal para preview de filtro - Picture-in-Picture: Continuar visualizando preview ao sair do app
- Mais Filtros: Sepia, Noir, Chrome, Fade, Transfer
- Export Progressivo: UI de progress durante concatenação/export
- Cloud Backup: Upload automático de vídeos finalizados
- Gesture Recording: Marcar pontos-chave durante gravação
- Background Upload: Continuar upload em background
- Metal Rendering: Acelerar aplicação de filtros com GPU
- Adaptive Quality: Ajustar resolução baseado em espaço disponível
- Segment Preloading: Pré-carregar próximo segmento para preview mais rápido
- Onboarding: Tutorial ao primeiro uso
- Gesture Hints: Dicas visuais de pinch/tap/double-tap
- Undo/Redo: Stack de ações reversíveis
- Project Management: Salvar projetos com múltiplos vídeos
- AVFoundation Programming Guide
- AVCaptureSession Class Reference
- Core Image Programming Guide
- SwiftUI Tutorials
- WWDC 2023: What's new in Camera Capture
- WWDC 2021: Discover ARKit 5
- WWDC 2020: Edit and play back HDR video with AVFoundation
Para uma experiência de leitura melhor, acesse a documentação completa no GitHub Pages:
🏠 Homepage: https://conty-app.github.io/camera-swift/
- 🚀 Começando - Setup e instalação
- 🏛️ Arquitetura - Visão geral MVVM
- 📦 Componentes - Documentação detalhada
- 📖 Guias - Tutoriais práticos
- ⚡ Técnico - Performance e escolhas técnicas
✅ 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
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.


