Skip to content

Commit

Permalink
Merge branch 'develop' into feat/#58/ai
Browse files Browse the repository at this point in the history
  • Loading branch information
simeunseo authored Dec 4, 2024
2 parents dbaf7b8 + 322d967 commit 8074756
Show file tree
Hide file tree
Showing 37 changed files with 260 additions and 290 deletions.
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,33 @@

<h1 id='기획_의도'>💭 기획 의도</h1>

> "부스트캠프의 **기술 공유** 시간이 참 좋은데, 시간이 한정적이다..."
부스트캠프에서는 월요일마다 "**기술 공유**"를 진행하여, 원하는 주제로 발표하고 공유하는 시간을 가졌습니다. 이 시간이 매우 유익했지만, 발표를 준비하고 참여하는 시간이 한정적이라고 느꼈습니다.

> "**시간에 구애받지 않고 누구나** 지식을 나눌 수 있는 플랫폼이 있다면 어떨까?"
> "준일님의 글쓰기 클래스처럼, 캠퍼들도 **각자의 전문성과 경험을 더욱 자유롭게 공유**할 수 있다면 좋지 않을까?"
> "**각자의 전문성과 경험을 더욱 자유롭게 공유**할 수 있다면 좋지 않을까?"
#### ➡️ 누구나, 언제든, 자유롭게 개설하고 참여하는 실시간 지식공유 플랫폼 **TICLE**❗️
#### ➡️ 누구나, 언제든, 자유롭게 개설하고 참여하는 실시간 지식 공유 플랫폼 **TICLE**❗️

<h1 id='핵심_목표'>🎯 핵심 목표</h1>

1️⃣ 실시간으로 발표자와 참여자가 즉각적인 피드백과 질문이 가능한 **양방향 소통 환경**

2️⃣ 모든 참여자가 **카메라, 마이크, 화면 공유**를 제어하고 활용할 수 있는 환경

3️⃣ **다수의 참여자가 동시에 접속**하여 최대한 많은 사람에게 지식 공유를 할 수 있는 안정적인 환경
3️⃣ 부스트캠프의 모든 캠퍼들이 동시에 접속할 수 있는 **안정적인 환경**

<h1 id='시스템_아키텍처'>🖧 시스템 아키텍처</h1>

![Cloudcraft Image (4)](https://github.com/user-attachments/assets/1e5874ee-2485-4e89-90a0-cebb47621c77)

최대한 많은 인원을 안정적으로 수용하고, 참가자 간 상호작용을 원활히 하기위해
다음과 같은 핵심 기술을 선택하였습니다.

1. 통신 지연 시간이 적고 별도의 플러그인 없이 사용할 수 있는 **WebRTC**
2. 선택적으로 스트림에 대한 수신을 결정하여 부하를 관리할 수 있는 **SFU 구조**
3. 세밀한 로직을 직접 설계할 수 있고, 스트림에 대한 로우 레벨의 제어가 가능한 **MediaSoup**

<h1 id='핵심_기능'>✴️ 핵심 기능</h1>

## **✔️ 누구나, 언제든, 자유롭게 실시간 지식 공유가 가능해요**
Expand Down Expand Up @@ -119,13 +126,16 @@

<img src='https://github.com/user-attachments/assets/825a5ea0-e873-40e1-a866-5706bde4bf5a' width=600 alt='AI 요약'/>

- 실시간 티클을 진행하면서 AI 음성 요약 버튼으로 녹음을 시작할 수 있어요.
- 티클이 종료되면 대시보드에서 요약본을 확인할 수 있어요.

<h1 id='우리만의_기술적_경험'>🏃 우리만의 기술적 경험</h1>

## 공통

<h4>WebRTC와 Mediasoup</h4>

> TICLE 프로젝트의 핵심 기술인 'WebRTC'와 'Mediasoup'에 대해 5명 팀원 모두가 학습 정리를 진행하였습니다.
> TICLE 프로젝트의 핵심 기술인 '**WebRTC**'와 '**Mediasoup**'에 대해 5명 팀원 모두가 학습 정리를 진행하였습니다.
- [WebRTC 정리 - 이지은, 황성하](https://simeunseo.notion.site/WebRTC-8c90ccf49d7c4ec5894222aeeb6de5a4?pvs=4)

Expand Down Expand Up @@ -166,10 +176,10 @@
> 로그인 성공 후 JWT를 쿠키에 넣은 뒤 redirection을 하여 클라이언트에서 쿠키를 조작할 수 없었습니다.
> 클라이언트 단일로 로그아웃이 불가능하여 쿠키 옵션에 대해 공부하며 로그아웃 기능을 구현했습니다.
<h4><a href='https://simeunseo.notion.site/nest-Throttler-143c27a1f6bf4feb9b0983e770c82b95?pvs=4'>🔗 게스트 로그인 구현과 @nest/Throttler</a></h4>
<h4><a href='https://simeunseo.notion.site/ffmpeg-4668724ddc4c4ec484c4e2551a11f577?pvs=4'>🔗 mediasoup + ffmpeg을 활용한 음성저장</a></h4>

> 캠퍼들이 저희의 서비스를 편하게 테스트할 수 있도록 게스트 로그인 기능을 구현하였습니다.
> 게스트 로그인을 구현하며 고민이였던 과도한 게스트 로그인 요청을 nest의 Throttler를 활용하여 해결하였습니다.
> 강의 AI 요약 기능을 위하여 회의실의 음성을 저장해야합니다
> mediasoup의 transport에 대해 간단히 학습하고 ffmpeg을 활용하여 음성저장 기능을 구현하였습니다.
<h4><a href='https://simeunseo.notion.site/clova-speech-clova-studio-ec1a9813b5a74439b32186b4fc5f8450?pvs=4'>🔗 Clova studio, Clova speech</a></h4>

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/ticle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ const getTitleList = async (params: GetTicleListParams = {}) => {
});
};

const getTicle = async (ticleId: string) => {
const getTicle = async (ticleId: string, userId: string) => {
return request<TicleDetailResponse>({
method: 'GET',
url: `/ticle/${ticleId}`,
url: `/ticle/${ticleId}?userId=${userId}`,
schema: TicleDetailResponseSchema,
});
};
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/common/Alert/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { cva } from 'class-variance-authority';
import { ReactNode } from 'react';

const alertVariants = cva('flex items-center justify-center', {
import ExclamationIc from '@/assets/icons/exclamation.svg?react';

const alertVariants = cva('flex items-center justify-center gap-2 text-body3', {
variants: {
type: {
info: 'text-black',
Expand All @@ -18,7 +20,12 @@ interface AlertProps {
}

function Alert({ children, type = 'info' }: AlertProps) {
return <div className={alertVariants({ type })}>{children}</div>;
return (
<div className={alertVariants({ type })}>
{type === 'error' && <ExclamationIc className="fill-error" width={12} height={12} />}
{children}
</div>
);
}

export default Alert;
14 changes: 11 additions & 3 deletions apps/web/src/components/dashboard/apply/TicleInfoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,17 @@ function TicleInfoCard({
</div>
</button>
)}
<Button disabled={status === 'closed'} onClick={handleTicleParticipate} className="w-36">
{status === 'closed' ? '종료된 티클' : '티클 참여하기'}
</Button>
<Button
disabled={status === 'closed' || status === 'open'}
onClick={handleTicleParticipate}
className="w-36"
>
{status === 'closed'
? '종료된 티클'
: status === 'inProgress'
? '티클 참여하기'
: '티클 시작 전'}
</Button>
</div>
{isOpen && (
<AiSummaryDialog onClose={onClose} isOpen={isOpen} ticleId={ticleId.toString()} />
Expand Down
18 changes: 11 additions & 7 deletions apps/web/src/components/live/ControlBar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useParams } from '@tanstack/react-router';
import { useNavigate } from '@tanstack/react-router';
import { SOCKET_EVENTS } from '@repo/mediasoup';

import CameraOffIc from '@/assets/icons/camera-off.svg?react';
Expand All @@ -12,7 +14,7 @@ import ToggleButton from '@/components/live/ControlBar/ToggleButton';
import ExitDialog from '@/components/live/ExitDialog';
import SettingDialog from '@/components/live/SettingDialog';
import { useLocalStreamAction, useLocalStreamState } from '@/contexts/localStream/context';
import { useMediasoupAction, useMediasoupState } from '@/contexts/mediasoup/context';
import { useMediasoupState } from '@/contexts/mediasoup/context';
import useModal from '@/hooks/useModal';

interface ControlBarProps {
Expand All @@ -21,6 +23,8 @@ interface ControlBarProps {
}

const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => {
const navigate = useNavigate({ from: '/live/$ticleId' });

const {
isOpen: isOpenExitModal,
onClose: onCloseExitModal,
Expand All @@ -36,23 +40,23 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => {
const { socketRef } = useMediasoupState();
const { video, screen, audio } = useLocalStreamState();

const { disconnect } = useMediasoupAction();
const {
closeStream,
pauseStream,
resumeStream,
startScreenStream,
startCameraStream,
startMicStream,
closeScreenStream,
} = useLocalStreamAction();

const { ticleId } = useParams({ from: '/_authenticated/live/$ticleId' });

const toggleScreenShare = async () => {
const { paused, stream } = screen;

try {
if (stream && !paused) {
closeScreenStream();
closeStream('screen');
} else {
startScreenStream();
}
Expand Down Expand Up @@ -93,11 +97,11 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => {

const handleExit = () => {
if (isOwner) {
socketRef.current?.emit(SOCKET_EVENTS.closeRoom);
socketRef.current?.emit(SOCKET_EVENTS.closeRoom, { roomId: ticleId });
onTicleEnd();
}

disconnect();
navigate({ to: '/', replace: true });
};

return (
Expand Down Expand Up @@ -137,7 +141,7 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => {
{isOpenExitModal && (
<ExitDialog
isOpen={isOpenExitModal}
isOwner={false}
isOwner={isOwner}
handleExit={handleExit}
onClose={onCloseExitModal}
/>
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/live/StreamView/List/Pinned.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StreamData } from '@/components/live/StreamView';
import { client } from '@repo/mediasoup';

import PaginationControls from '@/components/live/StreamView/List/PaginationControls';
import SubVideoGrid from '@/components/live/StreamView/List/SubVideoGrid';
import VideoPlayer from '@/components/live/StreamView/List/VideoPlayer';
Expand All @@ -7,11 +8,11 @@ import usePagination from '@/hooks/usePagination';
const ITEMS_PER_SUB_GRID = 4;

interface PinnedListProps {
pinnedVideoStreamData: StreamData;
pinnedVideoStreamData: client.RemoteStream;

addPinnedVideo: (stream: StreamData) => void;
addPinnedVideo: (stream: client.RemoteStream) => void;
removePinnedVideo: () => void;
getAudioMutedState: (stream: StreamData) => boolean;
getAudioMutedState: (stream: client.RemoteStream) => boolean;
}

function PinnedGrid({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function SubVideoGrid({
nickname={streamData.nickname}
stream={streamData.stream ?? null}
isMicOn={streamData && getAudioMutedState(streamData)}
mediaType={streamData.consumer?.appData?.mediaTypes}
mediaType={streamData.consumer?.appData?.mediaTypes ?? streamData.mediaType}
/>
</div>
))}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/live/StreamView/List/UnPinned.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { StreamData } from '@/components/live/StreamView';
import { client } from '@repo/mediasoup';

import PaginationControls from '@/components/live/StreamView/List/PaginationControls';
import VideoGrid from '@/components/live/StreamView/List/VideoGrid';
import usePagination from '@/hooks/usePagination';

const ITEMS_PER_GRID = 9;

interface UnPinnedListProps {
addPinnedVideo: (stream: StreamData) => void;
getAudioMutedState: (stream: StreamData) => boolean;
addPinnedVideo: (stream: client.RemoteStream) => void;
getAudioMutedState: (stream: client.RemoteStream) => boolean;
}

function UnPinnedGrid({ addPinnedVideo, getAudioMutedState }: UnPinnedListProps) {
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/components/live/StreamView/List/VideoGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { cva } from 'class-variance-authority';

import { StreamData } from '@/components/live/StreamView';
import { client } from '@repo/mediasoup';

import VideoPlayer from './VideoPlayer';

Expand All @@ -17,9 +16,9 @@ const containerVariants = cva('h-full flex-1 justify-center gap-5', {
});

interface VideoGridProps {
videoStreamData: StreamData[];
onVideoClick: (stream: StreamData) => void;
getAudioMutedState: (stream: StreamData) => boolean;
videoStreamData: client.RemoteStream[];
onVideoClick: (stream: client.RemoteStream) => void;
getAudioMutedState: (stream: client.RemoteStream) => boolean;
}

function VideoGrid({ videoStreamData, onVideoClick, getAudioMutedState }: VideoGridProps) {
Expand All @@ -36,7 +35,7 @@ function VideoGrid({ videoStreamData, onVideoClick, getAudioMutedState }: VideoG
nickname={streamData.nickname}
stream={streamData.stream ?? null}
isMicOn={streamData && getAudioMutedState(streamData)}
mediaType={streamData.consumer?.appData?.mediaTypes}
mediaType={streamData.consumer?.appData?.mediaTypes ?? streamData.mediaType}
/>
</div>
))}
Expand Down
12 changes: 0 additions & 12 deletions apps/web/src/components/live/StreamView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import { types } from 'mediasoup-client';
import { MediaTypes } from '@repo/mediasoup';

import AudioStreams from '@/components/live/StreamView/AudioStreams';
import PinnedGrid from '@/components/live/StreamView/List/Pinned';
import UnPinnedGrid from '@/components/live/StreamView/List/UnPinned';
import useAudioState from '@/hooks/useAudioState';
import usePinnedVideo from '@/hooks/usePinnedVideo';

export interface StreamData {
socketId: string;
nickname: string;
consumer?: types.Consumer<{ mediaTypes: MediaTypes; nickname: string }>;
kind?: types.MediaKind;
stream?: MediaStream | null;
paused?: boolean;
}

const StreamView = () => {
const { pinnedVideoStreamData, removePinnedVideo, selectPinnedVideo } = usePinnedVideo();
const { getAudioMutedState } = useAudioState();
Expand Down
18 changes: 10 additions & 8 deletions apps/web/src/components/live/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,37 @@ import { toast } from '@/core/toast';
import { useEndTicle } from '@/hooks/api/live';
import { useTicle } from '@/hooks/api/ticle';
import useMediasoup from '@/hooks/mediasoup/useMediasoup';
import useAuthStore from '@/stores/useAuthStore';
import { renderError } from '@/utils/toast/renderMessage';

function MediaContainer() {
useMediasoup();

const { ticleId } = useParams({ from: '/_authenticated/live/$ticleId' });
const navigate = useNavigate({ from: '/live/$ticleId' });
const userId = useAuthStore.getState().authInfo?.userId;

const { data: ticleData } = useTicle(ticleId);
const { data: ticleData, error } = useTicle(ticleId, userId || '');
const { mutate: endTicleMutate } = useEndTicle();

const isOwner = ticleData?.isOwner || false;
const hanleTicleEnd = () => {
endTicleMutate(ticleId);
};

useEffect(() => {
if (ticleData?.ticleStatus === 'closed') {
toast(renderError('종료된 티클입니다.'));
navigate({ to: '/' });
}
}, [ticleData?.ticleStatus, navigate]);
}, [ticleData?.ticleStatus, navigate, error]);

const isOwner = ticleData?.isOwner || false;
const handleTicleEnd = () => {
endTicleMutate(ticleId);
};

return (
<div className="flex h-dvh flex-col justify-between gap-y-4 bg-black">
<StreamView />
<footer className="flex w-full items-center justify-between gap-4 px-8 pb-4 text-white">
<span className="text-body1 text-white">{ticleData?.title}</span>
<ControlBar isOwner={isOwner} onTicleEnd={hanleTicleEnd} />
<ControlBar isOwner={isOwner} onTicleEnd={handleTicleEnd} />
</footer>
</div>
);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/ticle/detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import Badge from '@/components/common/Badge';
import UserProfileDialog from '@/components/user/UserProfileDialog';
import { useApplyTicle, useDeleteTicle, useTicle } from '@/hooks/api/ticle';
import useModal from '@/hooks/useModal';
import useAuthStore from '@/stores/useAuthStore';
import { formatDateTimeRange } from '@/utils/date';

import CtaButton from './CtaButton';

function Detail() {
const { ticleId } = useParams({ from: '/_authenticated/ticle/$ticleId' });
const { data } = useTicle(ticleId);
const { ticleId } = useParams({ from: '/ticle/$ticleId' });
const userId = useAuthStore.getState().authInfo?.userId;
const { data } = useTicle(ticleId, userId || '');
const { mutate: applyMutate } = useApplyTicle();
const { mutate: deleteMutate } = useDeleteTicle();

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ToastProps } from '@/core/toast/type';
import useToast from '@/hooks/toast/useToast';

const containerVariants = cva(
'z-0 mb-4 flex min-h-16 cursor-pointer justify-between overflow-hidden rounded-md bg-[#ffffffb3] text-white shadow-md [&.bounce-enter]:animate-bounce-in-bottom [&.bounce-exit]:animate-bounce-out-bottom',
'z-0 mb-4 flex min-h-16 cursor-pointer justify-between overflow-hidden rounded-md border border-main bg-[#ffffffb3] text-white shadow-md [&.bounce-enter]:animate-bounce-in-bottom [&.bounce-exit]:animate-bounce-out-bottom',
{
variants: {
closeOnClick: {
Expand Down
Loading

0 comments on commit 8074756

Please sign in to comment.