📚 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.


