[빙터뷰]실시간 모의면접 클라이언트 개발

impala·2023년 6월 28일
0
post-thumbnail

실시간 모의면접 기능은 발표자의 영상을 얼굴만 바꿔 같은 방에 있는 다른 사용자들에게 보여줘야 하기 때문에, 초기 서비스 기획시점에는 웹소켓을 통해 비디오스트림을 서버로 보내면 서버에서 영상처리를 한 뒤 다른 사용자들에게 변형된 비디오스트림을 보내주면 될 것이라고 생각했다.

하지만 라이브 스트리밍 서비스를 구축하는 것이 생각보다 간단한 작업이 아니었고, 프로젝트 마감기한이 얼마 남지 않아 여러 시행착오 끝에 agora라는 서드파티의 sdk를 사용하여 라이브 스트리밍 및 버추얼 아바타 기능을 구현하였다.

라이브 스트리밍 서비스

라이브 스트리밍 서비스를 개발하는 과정에서 웹소켓만을 사용하려다 보니 생각하는 대로 개발이 이루어지지 않았다.

처음에는 단순하게 실시간으로 영상을 보내면 될 거라고 생각해서 전면카메라를 매 프레임마다 캡쳐해서 서버로 보내려고 했다. 하지만 클라이언트에서 원하는 대로 동작하지 않았고, 오디오를 보낼 방법이 마땅치 않아 다른 방법을 찾아보았다.

두번째로 생각했던 방법은 짧은 간격으로 동영상을 녹화하여 임시파일을 만들고 이 파일을 서버로 전송하는 방법을 시도하려했다. 그러나 자료를 좀 더 찾아봤더니 라이브 스트리밍을 위해서는 웹소켓이 아닌 RTMP나 HLS같은 실시간 미디어 전송에 특화된 프로토콜을 사용하고, 비디오/오디오 코덱과 인코딩 및 CDN등 여러 기술이 복합적으로 얽혀서 사용된다는 것을 알게 되어 이 모든것을 남은 기간 안에 공부하여 직접 개발하는 것은 불가능하다고 판단하고 서드파티를 통해 관련 작업을 위임하였다.

일반적으로 라이브 스트리밍은 송출자가 원본영상을 라이브 인코딩 서버로 송출하면 영상을 인코딩(압축)하여 미디어(스트리밍) 서버로 보내고 추가적인 과정을 거쳐 전송서버 CDN으로 보내면 시청자는 전송서버에서 영상을 받아 플레이하는 방식으로 진행된다. 이 과정에서 여러 인코딩, 트랜스코딩 기법이나 HLS, RTMP등의 여러 프로토콜이 사용된다.

버추얼 아바타

실시간 모의면접 기능에서 얼굴을 변형하기 위해서 처음에는 realtime deepfake모델을 사용하고자 하였다. 하지만 실시간 서비스에 무거운 딥페이크 모델을 적용하기에는 시간이 너무 오래 걸려서 다른 방법을 찾아보았다.

agora를 채택하기 전 두번째로 생각한 방법은 클라이언트에서 face landmark를 추출하여 영상과 함께 보내면 서버에서 아바타를 입혀 브로드캐스팅하는 방법이었다. 이를 위해 google mlkit을 사용하여 face landmark를 추출하고 github를 참고하여 glb 아바타 모델을 영상에 적용하려고 시도했다. 하지만 three.js를 three.dart로 바꾸는 과정에서 gltf 파일을 로드하는 방법을 찾지 못해 실패했다.

그러던 중 라이브 스트리밍을 agora에 위임하기로 결정하고 사용법을 찾아보던 중, agora에서 지원하는 확장팩 중에서 faceunity라는 서드파티에서 라이브 영상에 아바타를 입혀주는 기능을 지원하여 이를 채택하기로 했다.

Agora

Agora는 실시간 보이스 콜이나 비디오 콜, 브로드캐스팅등의 기능을 제공하는 서드파티로, api나 sdk를 통해 관련 기능들을 간편하게 구현할 수 있고 여러 확장팩을 통해 다양한 기능을 추가할 수 있다. 이중 우리 서비스에서는 브로드캐스팅을 적용하였다.

Faceunity

Faceunity extension은 agora에서 지원하는 확장팩으로, agora 서비스에 간편하게 얼굴인식과 아바타, 보정등의 기능을 추가할 수 있는 확장팩이다. 우리 서비스에서는 얼굴을 완전히 가려야했기 때문에 animoji아바타를 사용하였다.

구현

class StreamingPage extends StatefulWidget {
  StreamingPage(
      {Key key,
       this.token,
       this.channelName,
       this.currentBroadcaster,
       this.isHost,
       this.onStart,
       this.onFinished})
      : super(key: key);

  String token;
  String channelName;
  String currentBroadcaster;
  bool isHost; // Indicates whether the user has joined as a host or audience
  WebSocketClient client;
  final void Function() onStart;
  final void Function() onFinished;

  
  _StreamingPageState createState() => _StreamingPageState();
}

class _StreamingPageState extends State<StreamingPage> {
  int uid = 0; // uid of the local user

  int _remoteUid; // uid of the remote user
  bool _isJoined = false; // Indicates if the local user has joined the channel
  bool _onAir = false;
  RtcEngine agoraEngine; // Agora engine instance

  Stage _stage;

  // Build UI
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Container for the local video
        Container(
          height: MediaQuery
              .of(context)
              .size
              .height * 0.48,
          decoration: BoxDecoration(
            border: Border.all(),

            // borderRadius: BorderRadius.circular(10), // 원하는 값으로 설정
            color: Colors.black54, // 원하는 색상으로 설정
          ),
          child: Center(
            child: _videoPanel(),
          ),
        ),
        SizedBox(
          height: 8,
        ),
        Visibility(
          visible: widget.isHost,
          child: ElevatedButton(
            onPressed: handleBroadcasting,
            style: ButtonStyle(
              backgroundColor:
              MaterialStateProperty.all<Color>(Colors.transparent),
              foregroundColor: MaterialStateProperty.all<Color>(
                  Colors.white),
              overlayColor: MaterialStateProperty.all<Color>(
                  Colors.blue.withOpacity(0.2)),
              shape: MaterialStateProperty.all<RoundedRectangleBorder>(
                RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(20.0),
                  side: BorderSide(
                    color: Color(0xFF8A61D4),
                  ),
                ),
              ),
              elevation: MaterialStateProperty.all<double>(5.0),
              padding:
              MaterialStateProperty.all<EdgeInsets>(EdgeInsets.all(15.0)),
            ),
            child: Text(
              _onAir ? '끝내기' : '시작하기',
              style: TextStyle(
                fontSize: 16,
                color: Colors.white,
              ),
            ),
          ),
        ),
      ],
    );
  }

  Widget _videoPanel() {
    if (!_isJoined) {
      return const Text(
        'Join a channel',
        textAlign: TextAlign.center,
      );
    } else if (widget.isHost) {
      // Show local video preview
      return AgoraVideoView(
        controller: VideoViewController(
          rtcEngine: agoraEngine,
          canvas: VideoCanvas(uid: 0),
        ),
      );
    } else {
      // Show remote video
      if (_remoteUid != null) {
        return AgoraVideoView(
          controller: VideoViewController.remote(
            rtcEngine: agoraEngine,
            canvas: VideoCanvas(uid: _remoteUid),
            connection: RtcConnection(channelId: widget.channelName),
            // connection: RtcConnection(channelId: "testChannel"),
          ),
        );
      } else {
        // 방송시작 안했을때
        return Container(
          margin: EdgeInsets.only(bottom: 8.0),
          child: Text(
            '참가자가 준비될 때까지 잠시만 기다려주세요!',
            textAlign: TextAlign.center,
            style: TextStyle(
              fontSize: 14,
              color: Colors.white,
              fontWeight: FontWeight.bold,
            ),
          ),
        );
      }
    }
  }

  
  void initState() {
    super.initState();
    // Set up an instance of Agora engine
    setupVideoSDKEngine();
  }


  Future<void> setupVideoSDKEngine() async {
    // retrieve or request camera and microphone permissions
    await [Permission.microphone, Permission.camera].request();

    //create an instance of the Agora engine
    agoraEngine = createAgoraRtcEngine();
    await agoraEngine.initialize(const RtcEngineContext(
        appId: appId
    ));

    if (Platform.isAndroid) {
      await agoraEngine.loadExtensionProvider(path: 'AgoraFaceUnityExtension');
    }

    await agoraEngine.enableExtension(
        provider: "FaceUnity", extension: "Effect", enable: true);


    await agoraEngine.enableVideo();

    // Register the event handler
    agoraEngine.registerEventHandler(
      RtcEngineEventHandler(
        onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
          setState(() {
            _isJoined = true;
          });
        },
        onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) {
          setState(() {
            _remoteUid = remoteUid;
          });
        },
        onUserOffline: (RtcConnection connection, int remoteUid,
            UserOfflineReasonType reason) {
          setState(() {
            _remoteUid = null;
          });
        },
        onExtensionStarted: (provider, extension) {
          debugPrint(
              '[onExtensionStarted] provider: $provider, extension: $extension');
          if (provider == 'FaceUnity' && extension == 'Effect') {
            initializeFaceUnityExt();
          }
        },
        onExtensionError: (provider, extension, error, message) {
          debugPrint(
              '[onExtensionError] provider: $provider, '
                  'extension: $extension, error: $error, message: $message');
        },
        onExtensionEvent: (String provider, String extName, String key, String value) {
          debugPrint(
              '[onExtensionEvent] provider: $provider, '
                  'extension: $extName, key: $key, value: $value');
        },
        onError: (e, msg) {
          print("[에러발생] :$e, $msg");
        },
      ),
    );

    join();
  }


  
  void dispose() async {
    await agoraEngine.leaveChannel();
    agoraEngine.release();

    await agoraEngine.setExtensionProperty(
        provider: 'FaceUnity',
        extension: 'Effect',
        key: 'fuDestroyLibData',
        value: jsonEncode({})
    );

    super.dispose();
  }

  Future<void> initializeFaceUnityExt() async {
    // Initialize the extension and authenticate the user
    await agoraEngine.setExtensionProperty(
        provider: 'FaceUnity',
        extension: 'Effect',
        key: 'fuSetup',
        value: jsonEncode({'authdata': authpack.gAuthPackage}));

    // Load the AI model
    final aiFaceProcessorPath =
    await _copyAsset('Resource/model/ai_face_processor.bundle');
    await agoraEngine.setExtensionProperty(
        provider: 'FaceUnity',
        extension: 'Effect',
        key: 'fuLoadAIModelFromPackage',
        value: jsonEncode({'data': aiFaceProcessorPath, 'type': 1 << 8}));

    // Load the qgirl prop
    final itemPath = await _copyAsset('Resource/items/Animoji/qgirl.bundle');

    await agoraEngine.setExtensionProperty(
        provider: 'FaceUnity',
        extension: 'Effect',
        key: 'fuCreateItemFromPackage',
        value: jsonEncode({'data': itemPath}));
  }

  Future<String> _copyAsset(String assetPath) async {
    ByteData data = await rootBundle.load(assetPath);
    List<int> bytes =
    data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);

    Directory appDocDir = await getApplicationDocumentsDirectory();

    final dirname = path.dirname(assetPath);

    Directory dstDir = Directory(path.join(appDocDir.path, dirname));
    if (!(await dstDir.exists())) {
      await dstDir.create(recursive: true);
    }

    String p = path.join(appDocDir.path, path.basename(assetPath));
    final file = File(p);
    if (!(await file.exists())) {
      await file.create();
      await file.writeAsBytes(bytes);
    }

    return file.absolute.path;
  }


  void handleBroadcasting() {
    if(_onAir) {
      leave();
    } else {
      setState(() {
        _onAir = true;
      });
      widget.onStart.call();
    }
  }

  void join() async {
    if (widget.token == null && widget.channelName == null) {
      return;
    }

    // Set channel options
    ChannelMediaOptions options;

    // Set channel profile and client role
    if (widget.isHost) {
      print("Broadcast start");
      options = const ChannelMediaOptions(
        clientRoleType: ClientRoleType.clientRoleBroadcaster,
        channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
      );
      await agoraEngine.startPreview();
    } else {
      print("Watch broadcasting");
      options = const ChannelMediaOptions(
        clientRoleType: ClientRoleType.clientRoleAudience,
        channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
        // Set the latency level
        audienceLatencyLevel:
            AudienceLatencyLevelType.audienceLatencyLevelLowLatency,
      );
    }

    try {
      await agoraEngine.joinChannel(
        token: widget.token,
        channelId: widget.channelName,
        options: options,
        uid: uid,
      );
    } catch (e) {
      print("[joinChannel] error : $e");
    }

    setState(() {
      _isJoined = true;
    });
  }

  void leave() {
    _leaveChannel();
    widget.onFinished.call();
    setState(() {
      widget.isHost = false;
    });
  }

  void _leaveChannel() {
    setState(() {
      _onAir = false;
      _isJoined = false;
      _remoteUid = null;
    });
    agoraEngine.leaveChannel();
  }
}

대부분의 코드는 agora sdk quickstart(broadcasting)agora sdk integrate extension(faceunity)를 참고하여 개발하였다.

짧은 시간 내에 sdk의 사용법을 익히고 우리 코드에 맞게 적용하는 과정이 쉽지는 않았지만 여러 시도끝에 잘 마무리할 수 있었다.

0개의 댓글