Capture Vidéo et Encodage Matériel H.264 sur iOS

Cet article décrit la mise en œuvre de la capture vidéo depuis la caméra d'un appareil iOS, suivie de son encodage matériel en H.264 à l'aide des frameworks AVFoundation et VideoToolbox. Il présente une approche pour configurer une session de capture, gérer les flux vidéo et encoder les images en temps réel.

Pour commencer, définissons l'interface de notre contrôleur de vue principal. Ce contrôleur sera responsalbe de la gestion de la capture vidéo et de l'affichage de la prévisualisation.

#import <AVFoundation/AVFoundation.h>
#import <VideoToolbox/VideoToolbox.h>
#import <UIKit/UIKit.h>

@interface ControleurCaptureVideo : UIViewController <AVCaptureVideoDataOutputSampleBufferDelegate>

// Vue pour afficher la prévisualisation de la caméra
@property (strong, nonatomic) IBOutlet UIImageView *vuePrevisualisationCamera;
// Bouton pour démarrer/arrêter la capture
@property (strong, nonatomic) IBOutlet UIButton *boutonDemarrerArreter;

@end

Le code d'implémentation inclura la configuration de la session de capture, l'initialisation de l'encodeur VideoToolbox et le traitement des buffers d'échantillons vidéo.

// ControleurCaptureVideo.m
#import "ControleurCaptureVideo.h"
#import <CoreGraphics/CoreGraphics.h>
#import <CoreVideo/CoreVideo.h>
#import <CoreMedia/CoreMedia.h>

@interface ControleurCaptureVideo ()

// Référence à la session de compression VideoToolbox
@property (assign, nonatomic) VTCompressionSessionRef sessionCompressionH264;

@end

@implementation ControleurCaptureVideo {
    AVCaptureSession *_sessionCapture;               // Session de capture AVFoundation
    AVCaptureVideoPreviewLayer *_couchePrevisualisation; // Couche pour l'affichage en direct
    BOOL _estCaptureActive;                          // Indicateur de l l'état de la capture
    AVCaptureConnection *_connexionVideo;            // Connexion pour le flux vidéo
    AVCaptureConnection *_connexionAudio;            // Connexion pour le flux audio (non utilisé dans cet exemple)

    // Variables pour l'écriture du fichier H.264 encodé
    NSString *_cheminFichierH264;
    NSFileHandle *_gestionnaireFichierH264;
}

#pragma mark - Cycle de vie du contrôleur de vue

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // Initialisation et configuration de l'interface utilisateur
    _estCaptureActive = NO;
    self.vuePrevisualisationCamera.frame = CGRectMake(20, 20, 200, 200);
    self.boutonDemarrerArreter.frame = CGRectMake(30, 230, 120, 50);
    [self.boutonDemarrerArreter setTitle:@"Démarrer Capture" forState:UIControlStateNormal];
    [self.boutonDemarrerArreter addTarget:self action:@selector(toggleCaptureAction:) forControlEvents:UIControlEventTouchUpInside];

    // Configuration des composants de capture et d'encodage
    [self configurerSessionAVCapture];
    [self configurerAffichagePrevisualisation];
    [self initialiserEncodeurH264];
}

- (void)dealloc {
    // Nettoyage de la session de compression lors de la désallocation
    if (_sessionCompressionH264) {
        VTCompressionSessionCompleteFrames(_sessionCompressionH264, kCMTimeInvalid);
        VTCompressionSessionInvalidate(_sessionCompressionH264);
        CFRelease(_sessionCompressionH264);
        _sessionCompressionH264 = NULL;
    }
    // Fermer le fichier si ouvert
    [_gestionnaireFichierH264 closeFile];
}

#pragma mark - Configuration de la capture vidéo

// Configure la session de capture AVFoundation
- (void)configurerSessionAVCapture {
    _sessionCapture = [[AVCaptureSession alloc] init];
    _sessionCapture.usesApplicationAudioSession = NO; // Éviter les conflits audio

    NSError *erreur = nil;
    AVCaptureDevice *appareilVideo = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
    AVCaptureDeviceInput *entreeVideo = [AVCaptureDeviceInput deviceInputWithDevice:appareilVideo error:&erreur];

    if (erreur) {
        NSLog(@"Erreur lors de l'initialisation de l'entrée vidéo : %@", erreur.localizedDescription);
        return;
    }

    // Tenter d'ajouter l'entrée vidéo à la session
    if ([_sessionCapture canAddInput:entreeVideo]) {
        [_sessionCapture addInput:entreeVideo];
    } else {
        NSLog(@"Impossible d'ajouter l'entrée vidéo à la session.");
        return;
    }
    
    // Configuration du débit d'images (framerate)
    // AVFoundation gère la résolution et le framerate de manière complexe.
    // L'approche ici est de définir une durée minimale/maximale pour chaque image.
    NSInteger framerateDesire = 30; // Nombre d'images par seconde souhaité
    CMTime minFrameDuration = CMTimeMake(1, (int32_t)framerateDesire);
    CMTime maxFrameDuration = minFrameDuration;

    // Vérifier les formats supportés pour le périphérique
    [appareilVideo lockForConfiguration:nil];
    appareilVideo.activeVideoMinFrameDuration = minFrameDuration;
    appareilVideo.activeVideoMaxFrameDuration = maxFrameDuration;
    [appareilVideo unlockForConfiguration];
    
    // Configuration de la sortie des données vidéo
    AVCaptureVideoDataOutput *sortieDonneesVideo = [[AVCaptureVideoDataOutput alloc] init];
    sortieDonneesVideo.alwaysDiscardsLateVideoFrames = YES; // Ne pas conserver les images en retard

    // Définir le format des pixels à YUV 420 bi-planar (NV12)
    NSDictionary *reglagesVideo = @{
        (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
    };
    [sortieDonneesVideo setVideoSettings:reglagesVideo];

    // File d'attente pour le délégué de sortie
    dispatch_queue_t fileAttenteVideo = dispatch_queue_create("fr.capture.videoQueue", NULL);
    [sortieDonneesVideo setSampleBufferDelegate:self queue:fileAttenteVideo];

    // Tenter d'ajouter la sortie vidéo à la session
    if ([_sessionCapture canAddOutput:sortieDonneesVideo]) {
        [_sessionCapture addOutput:sortieDonneesVideo];
    } else {
        NSLog(@"Impossible d'ajouter la sortie vidéo à la session.");
        return;
    }

    // Récupérer les connexions pour identifier les données vidéo/audio
    _connexionVideo = [sortieDonneesVideo connectionWithMediaType:AVMediaTypeVideo];
    _connexionAudio = [sortieDonneesVideo connectionWithMediaType:AVMediaTypeAudio]; // Non utilisé pour l'encodage vidéo

    // Définir la résolution de la session
    if ([_sessionCapture canSetSessionPreset:AVCaptureSessionPreset1280x720]) {
        _sessionCapture.sessionPreset = AVCaptureSessionPreset1280x720;
    } else {
        NSLog(@"Impossible de définir la résolution AVCaptureSessionPreset1280x720.");
    }
}

// Configure la couche de prévisualisation de la caméra
- (void)configurerAffichagePrevisualisation {
    _couchePrevisualisation = [AVCaptureVideoPreviewLayer layerWithSession:_sessionCapture];
    _couchePrevisualisation.videoGravity = AVLayerVideoGravityResizeAspectFill; // Mode de remplissage
    
    // S'assurer que la prévisualisation est en mode portrait
    if ([_couchePrevisualisation.connection isVideoOrientationSupported]) {
        [_couchePrevisualisation.connection setVideoOrientation:AVCaptureVideoOrientationPortrait];
    }
    
    _couchePrevisualisation.frame = self.vuePrevisualisationCamera.layer.bounds;
    [self.vuePrevisualisationCamera.layer addSublayer:_couchePrevisualisation];
}

#pragma mark - Gestion de la capture

- (IBAction)toggleCaptureAction:(id)sender {
    if (_estCaptureActive) {
        [self arreterCapture];
    } else {
        [self demarrerCapture];
    }
}

// Démarre la capture vidéo
- (BOOL)demarrerCapture {
    if (_estCaptureActive) {
        return NO;
    }

    // Vérifier les permissions de la caméra
    AVAuthorizationStatus statutVideo = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
    if (statutVideo == AVAuthorizationStatusDenied || statutVideo == AVAuthorizationStatusRestricted) {
        NSLog(@"Accès à la caméra refusé. Veuillez accorder les permissions dans les réglages.");
        return NO;
    }
    
    // Nettoyer le fichier H.264 existant et en créer un nouveau pour l'écriture
    NSArray *cheminsDoc = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *repertoireDocuments = [cheminsDoc objectAtIndex:0];
    _cheminFichierH264 = [repertoireDocuments stringByAppendingPathComponent:@"video_encodee.h264"];
    [[NSFileManager defaultManager] removeItemAtPath:_cheminFichierH264 error:nil];
    [[NSFileManager defaultManager] createFileAtPath:_cheminFichierH264 contents:nil attributes:nil];
    _gestionnaireFichierH264 = [NSFileHandle fileHandleForWritingAtPath:_cheminFichierH264];
    
    if (!_gestionnaireFichierH264) {
        NSLog(@"Erreur : Impossible d'ouvrir le fichier de sortie H.264.");
        return NO;
    }

    [_sessionCapture startRunning];
    _estCaptureActive = YES;
    [self.boutonDemarrerArreter setTitle:@"Arrêter Capture" forState:UIControlStateNormal];
    NSLog(@"Capture démarrée et encodage en cours vers : %@", _cheminFichierH264);
    return YES;
}

// Arrête la capture vidéo
- (BOOL)arreterCapture {
    if (!_estCaptureActive) {
        return NO;
    }
    [_sessionCapture stopRunning];
    _estCaptureActive = NO;
    [self.boutonDemarrerArreter setTitle:@"Démarrer Capture" forState:UIControlStateNormal];
    
    // Finaliser la session d'encodage et fermer le fichier
    [self arreterEncodageVideo];
    [_gestionnaireFichierH264 closeFile];
    _gestionnaireFichierH264 = nil;
    NSLog(@"Capture arrêtée et fichier H.264 fermé.");
    return YES;
}

#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate

// Délégué appelé lorsque de nouvelles données vidéo/audio sont disponibles
- (void)captureOutput:(AVCaptureOutput *)output
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
       fromConnection:(AVCaptureConnection *)connection {
    
    if (connection == _connexionVideo) {
        // C'est un buffer vidéo
        CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
        if (!pixelBuffer) {
            NSLog(@"Le sampleBuffer vidéo ne contient pas de CVPixelBuffer.");
            return;
        }
        
        // Verrouiller le buffer de pixels pour accéder aux données
        CVPixelBufferLockBaseAddress(pixelBuffer, 0);
        
        // Encodage H.264
        [self encoderFrameVideo:sampleBuffer forcerImageClef:NO];
        
        // Déverrouiller le buffer de pixels
        CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
        
    } else if (connection == _connexionAudio) {
        // C'est un buffer audio, ignorer pour cet exemple
    }
}

#pragma mark - Encodage H.264 avec VideoToolbox

// Callback appelé par VideoToolbox lorsque l'encodage est terminé pour une frame
void encodageH264Callback(void * CM_NULLABLE outputCallbackRefCon,
                            void * CM_NULLABLE sourceFrameRefCon,
                            OSStatus status,
                            VTEncodeInfoFlags infoFlags,
                            CM_NULLABLE CMSampleBufferRef sampleBuffer) {
    
    if (noErr != status || sampleBuffer == NULL) {
        NSLog(@"Erreur d'encodage H.264 : %d", (int)status);
        return;
    }
    
    if (!CMSampleBufferDataIsReady(sampleBuffer)) {
        NSLog(@"Le sampleBuffer encodé n'est pas prêt.");
        return;
    }
    
    // Récupérer l'instance du contrôleur pour accéder au gestionnaire de fichier
    ControleurCaptureVideo *selfRef = (__bridge ControleurCaptureVideo *)outputCallbackRefCon;
    if (!selfRef || !selfRef->_gestionnaireFichierH264) {
        return;
    }

    // Préfixe NAL unit (start code)
    const uint8_t enteteNAL[] = {0x00, 0x00, 0x00, 0x01};
    NSData *dataEnteteNAL = [NSData dataWithBytes:enteteNAL length:sizeof(enteteNAL)];

    // Vérifier si c'est une image clé (I-frame)
    BOOL estImageClef = !CFDictionaryContainsKey((CFDictionaryRef)CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0), kCMSampleAttachmentKey_NotSync);

    if (estImageClef) {
        NSLog(@"Image clé H.264 encodée.");
        // Pour les images clés, extraire les paramètres SPS et PPS
        CMFormatDescriptionRef descriptionFormat = CMSampleBufferGetFormatDescription(sampleBuffer);
        
        // SPS (Sequence Parameter Set)
        size_t tailleSPS, nombreSPS;
        const uint8_t *ptrSPS;
        CMVideoFormatDescriptionGetH264ParameterSetAtIndex(descriptionFormat, 0, &ptrSPS, &tailleSPS, &nombreSPS, NULL);
        NSData *spsData = [NSData dataWithBytes:ptrSPS length:tailleSPS];
        
        // PPS (Picture Parameter Set)
        size_t taillePPS, nombrePPS;
        const uint8_t *ptrPPS;
        CMVideoFormatDescriptionGetH264ParameterSetAtIndex(descriptionFormat, 1, &ptrPPS, &taillePPS, &nombrePPS, NULL);
        NSData *ppsData = [NSData dataWithBytes:ptrPPS length:taillePPS];
        
        // Écrire SPS et PPS au fichier, chacun précédé du start code
        NSMutableData *dataSPSComplete = [NSMutableData dataWithData:dataEnteteNAL];
        [dataSPSComplete appendData:spsData];
        [selfRef->_gestionnaireFichierH264 writeData:dataSPSComplete];
        
        NSMutableData *dataPPSComplete = [NSMutableData dataWithData:dataEnteteNAL];
        [dataPPSComplete appendData:ppsData];
        [selfRef->_gestionnaireFichierH264 writeData:dataPPSComplete];
    }
    
    // Récupérer les données NAL de la frame encodée
    CMBlockBufferRef bufferBloc = CMSampleBufferGetDataBuffer(sampleBuffer);
    size_t longueurNAL, longueurTotaleNAL;
    char *pointeurDonneesNAL;
    status = CMBlockBufferGetDataPointer(bufferBloc, 0, &longueurNAL, &longueurTotaleNAL, &pointeurDonneesNAL);
    
    if (noErr != status) {
        NSLog(@"Erreur CMBlockBufferGetDataPointer : %d", (int)status);
        return;
    }
    
    // Les unités NAL dans le CMSampleBuffer sont préfixées par leur longueur (4 octets, big-endian)
    size_t offsetBuffer = 0;
    const int longueurEnteteAVC = 4; // Longueur du champ de taille NAL
    
    while (offsetBuffer < longueurTotaleNAL - longueurEnteteAVC) {
        uint32_t longueurUniteNAL = 0;
        memcpy(&longueurUniteNAL, pointeurDonneesNAL + offsetBuffer, longueurEnteteAVC);
        longueurUniteNAL = CFSwapInt32BigToHost(longueurUniteNAL); // Convertir big-endian en hôte
        
        if (longueurUniteNAL > longueurTotaleNAL - offsetBuffer - longueurEnteteAVC) {
            NSLog(@"Erreur : Longueur NAL invalide, potentielle corruption de données.");
            break;
        }

        NSData *dataUniteNAL = [[NSData alloc] initWithBytes:(pointeurDonneesNAL + offsetBuffer + longueurEnteteAVC)
                                                     length:longueurUniteNAL];
        
        // Écrire le start code suivi des données NAL
        NSMutableData *dataFrameComplete = [NSMutableData dataWithData:dataEnteteNAL];
        [dataFrameComplete appendData:dataUniteNAL];
        [selfRef->_gestionnaireFichierH264 writeData:dataFrameComplete];
        
        offsetBuffer += longueurEnteteNAL + longueurUniteNAL;
    }
    [selfRef->_gestionnaireFichierH264 synchronizeFile]; // S'assurer que les données sont écrites sur le disque
}


// Initialise la session de compression H.264
- (void)initialiserEncodeurH264 {
    // Créer la session de compression VideoToolbox
    OSStatus status = VTCompressionSessionCreate(NULL,
                                                 1280, // Largeur de la vidéo
                                                 720,  // Hauteur de la vidéo
                                                 kCMVideoCodecType_H264, // Type de codec
                                                 NULL, // Dictionnaire d'allocateur (par défaut)
                                                 NULL, // Dictionnaire d'attributs de source d'image (par défaut)
                                                 NULL, // Dictionnaire d'encodeur (par défaut)
                                                 encodageH264Callback, // Fonction de callback pour la sortie
                                                 (__bridge void *)(self), // Contexte de référence pour le callback (ici, le contrôleur)
                                                 &_sessionCompressionH264);
    
    if (noErr != status) {
        NSLog(@"Erreur VTCompressionSessionCreate : %d", (int)status);
        return;
    }

    // Définir le débit binaire moyen (Average Bit Rate)
    // 512 kbps, exemple. Ajustez selon les besoins de qualité/bande passante.
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_AverageBitRate,
                                  (__bridge CFTypeRef)@(512 * 1024));
    
    // Définir le profil H.264 (Baseline 3.1 pour une large compatibilité)
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_ProfileLevel,
                                  kVTProfileLevel_H264_Baseline_3_1);
    
    // Activer l'encodage en temps réel pour minimiser la latence
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_RealTime,
                                  kCFBooleanTrue);
    
    // Désactiver le réordonnancement des frames (pas de B-frames) pour un stream plus simple
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_AllowFrameReordering,
                                  kCFBooleanFalse);
    
    // Définir le framerate attendu
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_ExpectedFrameRate,
                                  (__bridge CFTypeRef)@(30));
    
    // Définir l'intervalle maximum entre les images clés (GOP size)
    // Ici, une image clé toutes les 2 secondes (30 fps * 2s = 60 frames)
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_MaxKeyFrameInterval,
                                  (__bridge CFTypeRef)@(60));
    
    // Définir la durée maximale de l'intervalle entre les images clés
    status = VTSessionSetProperty(_sessionCompressionH264,
                                  kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration,
                                  (__bridge CFTypeRef)@(2.0));

    // Préparer l'encodeur à recevoir des frames
    status = VTCompressionSessionPrepareToEncodeFrames(_sessionCompressionH264);
    if (noErr != status) {
        NSLog(@"Erreur VTCompressionSessionPrepareToEncodeFrames : %d", (int)status);
    }
}

// Soumet une frame vidéo pour l'encodage H.264
- (BOOL)encoderFrameVideo:(CMSampleBufferRef)sampleBuffer forcerImageClef:(BOOL)forcerImageClef {
    if (_sessionCompressionH264 == NULL || sampleBuffer == NULL) {
        return NO;
    }
    
    CVImageBufferRef bufferPixel = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
    
    // Options pour forcer une image clé
    NSDictionary *proprietesFrame = nil;
    if (forcerImageClef) {
        proprietesFrame = @{(__bridge NSString *)kVTEncodeFrameOptionKey_ForceKeyFrame : @(YES)};
    }
    
    // Envoyer la frame à la session de compression
    OSStatus status = VTCompressionSessionEncodeFrame(_sessionCompressionH264,
                                                      bufferPixel,
                                                      CMSampleBufferGetPresentationTimeStamp(sampleBuffer), // Horodatage de présentation
                                                      kCMTimeInvalid, // Durée non spécifiée, l'encodeur la détermine
                                                      (__bridge CFDictionaryRef)proprietesFrame, // Propriétés de la frame
                                                      NULL, // Contexte de référence de la frame (non utilisé)
                                                      NULL); // Info de la frame (non utilisé)
    
    if (noErr != status) {
        NSLog(@"Erreur VTCompressionSessionEncodeFrame : %d", (int)status);
        return NO;
    }
    return YES;
}

// Arrête la session d'encodage vidéo
- (BOOL)arreterEncodageVideo {
    if (_sessionCompressionH264 == NULL) {
        return NO;
    }
    
    // Demander à la session de compression de finaliser toutes les frames en attente
    OSStatus status = VTCompressionSessionCompleteFrames(_sessionCompressionH264, kCMTimeInvalid);
    
    if (noErr != status) {
        NSLog(@"Erreur VTCompressionSessionCompleteFrames : %d", (int)status);
        return NO;
    }
    return YES;
}

@end

Étiquettes: iOS AVFoundation VideoToolbox H.264 Capture Vidéo

Publié le 22 juillet à 19h00