diff --git a/README.md b/README.md index af8d7e9a..54abff5e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ ## 프론트엔드 +

🔗 네트워크 상태에 따른 스트림 품질 변경

🔗 스트리밍을 최적화 해보자

> 최대한 많은 유저가 들어와 화상 서비스를 이용하는 것을 목적으로 하는 만큼 소켓 이벤트, 기존 할당된 자원에 대한 관리를 진행하며 최대한 적은 자원으로 나은 환경을 제공하기 위해 최적화를 진행하였습니다. diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts index 96823f0f..01d9017b 100644 --- a/apps/api/src/dashboard/dashboard.module.ts +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Applicant } from '@/entity/applicant.entity'; +import { Summary } from '@/entity/summary.entity'; import { Ticle } from '@/entity/ticle.entity'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @Module({ - imports: [TypeOrmModule.forFeature([Ticle, Applicant])], + imports: [TypeOrmModule.forFeature([Ticle, Applicant, Summary])], controllers: [DashboardController], providers: [DashboardService], }) diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 78b89263..6bbdf8bb 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { ErrorMessage, TicleStatus } from '@repo/types'; import { Applicant } from '@/entity/applicant.entity'; +import { Summary } from '@/entity/summary.entity'; import { Ticle } from '@/entity/ticle.entity'; @Injectable() @@ -12,7 +13,9 @@ export class DashboardService { @InjectRepository(Ticle) private readonly ticleRepository: Repository, @InjectRepository(Applicant) - private readonly applicantRepository: Repository + private readonly applicantRepository: Repository, + @InjectRepository(Summary) + private readonly summaryRepository: Repository ) {} async getCreatedTicleList( @@ -25,7 +28,15 @@ export class DashboardService { const queryBuilder = this.ticleRepository .createQueryBuilder('ticle') - .select(['ticle.id', 'ticle.title', 'ticle.startTime', 'ticle.endTime', 'ticle.ticleStatus']) + .leftJoin('ticle.summary', 'summary') + .select([ + 'ticle.id', + 'ticle.title', + 'ticle.startTime', + 'ticle.endTime', + 'ticle.ticleStatus', + 'summary.id', + ]) .where('ticle.speaker = :speakerId', { speakerId }) .skip(skip) .take(pageSize); @@ -40,7 +51,12 @@ export class DashboardService { } } - const [ticles, totalItems] = await queryBuilder.getManyAndCount(); + const [ticle, totalItems] = await queryBuilder.getManyAndCount(); + + const ticles = ticle.map((ticle) => ({ + ...ticle, + summary: ticle.summary ? ticle.summary.id !== null : false, + })); const totalPages = Math.ceil(totalItems / pageSize); @@ -62,6 +78,7 @@ export class DashboardService { const queryBuilder = this.applicantRepository .createQueryBuilder('applicant') .leftJoinAndSelect('applicant.ticle', 'ticle') + .leftJoin('ticle.summary', 'summary') .select([ 'applicant.id', 'ticle.id', @@ -70,6 +87,7 @@ export class DashboardService { 'ticle.startTime', 'ticle.endTime', 'ticle.ticleStatus', + 'summary.id', ]) .where('applicant.user = :userId', { userId }) .skip(skip) @@ -87,7 +105,11 @@ export class DashboardService { const [applicants, totalItems] = await queryBuilder.getManyAndCount(); - const ticles = applicants.map((applicant) => applicant.ticle); + const ticles = applicants.map((applicant) => ({ + ...applicant.ticle, + summary: applicant.ticle.summary ? applicant.ticle.summary.id !== null : false, + })); + const totalPages = Math.ceil(totalItems / pageSize); return { diff --git a/apps/api/src/entity/ticle.entity.ts b/apps/api/src/entity/ticle.entity.ts index c79d50bc..59ea193f 100644 --- a/apps/api/src/entity/ticle.entity.ts +++ b/apps/api/src/entity/ticle.entity.ts @@ -34,7 +34,7 @@ export class Ticle { @Column({ type: 'varchar', name: 'speaker_email' }) speakerEmail: string; - @Column({ type: 'varchar', name: 'speaker_introduce' }) + @Column({ type: 'text', name: 'speaker_introduce' }) speakerIntroduce: string; @Column({ type: 'varchar' }) diff --git a/apps/media/src/mediasoup/config.ts b/apps/media/src/mediasoup/config.ts index 7371a5e8..0d27a8f5 100644 --- a/apps/media/src/mediasoup/config.ts +++ b/apps/media/src/mediasoup/config.ts @@ -29,10 +29,22 @@ export class MediasoupConfig { useinbandfec: 1, }, }, + { + kind: 'video', + mimeType: 'video/H264', + clockRate: 90000, + parameters: { + 'packetization-mode': 1, + 'profile-level-id': '42e01f', + }, + }, { kind: 'video', mimeType: 'video/VP8', clockRate: 90000, + parameters: { + 'x-google-start-bitrate': 10000, + }, }, ] as RtpCodecCapability[], }; diff --git a/apps/media/src/mediasoup/mediasoup.service.ts b/apps/media/src/mediasoup/mediasoup.service.ts index d389c86b..e82cf215 100644 --- a/apps/media/src/mediasoup/mediasoup.service.ts +++ b/apps/media/src/mediasoup/mediasoup.service.ts @@ -48,7 +48,7 @@ export class MediasoupService implements OnModuleInit { return worker; } - async createRoom(roomId: string) { + async createRoom(roomId: string, masterSocketId: string) { const isExistRoom = this.roomService.existRoom(roomId); if (isExistRoom) { return roomId; @@ -59,7 +59,7 @@ export class MediasoupService implements OnModuleInit { mediaCodecs: this.mediasoupConfig.router.mediaCodecs, }); - return this.roomService.createRoom(roomId, router); + return this.roomService.createRoom(roomId, router, masterSocketId); } joinRoom(roomId: string, socketId: string, nickname: string) { @@ -113,10 +113,6 @@ export class MediasoupService implements OnModuleInit { const peer = room.getPeer(socketId); const transport = peer.getTransport(transportId); - if (appData.mediaTypes !== 'audio') { - rtpParameters.encodings = server.PRODUCER_OPTIONS.encodings; - } - const producer = await transport.produce({ kind, rtpParameters, @@ -269,6 +265,8 @@ export class MediasoupService implements OnModuleInit { const peer = room.peers.get(socketId); const consumer = peer.getConsumer(consumerId); + if (!consumer) return; + consumer?.pause(); return { paused: true, consumerId, producerId: consumer.producerId }; @@ -279,6 +277,8 @@ export class MediasoupService implements OnModuleInit { const peer = room.peers.get(socketId); const consumer = peer.getConsumer(consumerId); + if (!consumer) return; + if (consumer?.producerPaused) { return { paused: true, consumerId, producerId: consumer.producerId }; } @@ -289,11 +289,15 @@ export class MediasoupService implements OnModuleInit { } pauseConsumers(socketId: string, roomId: string, consumerIds: string[]) { - return consumerIds.map((consumerId) => this.pauseConsumer(socketId, consumerId, roomId)); + return consumerIds + .map((consumerId) => this.pauseConsumer(socketId, consumerId, roomId)) + .filter(Boolean); } resumeConsumers(socketId: string, roomId: string, consumerIds: string[]) { - return consumerIds.map((consumerId) => this.resumeConsumer(socketId, consumerId, roomId)); + return consumerIds + .map((consumerId) => this.resumeConsumer(socketId, consumerId, roomId)) + .filter(Boolean); } changeConsumerPreferredLayers( @@ -307,10 +311,9 @@ export class MediasoupService implements OnModuleInit { const consumer = peer.getConsumer(consumerId); - consumer?.setPreferredLayers({ - spatialLayer: networkQuality, - temporalLayer: networkQuality, - }); + if (!consumer || consumer.closed || consumer.paused) return; + + consumer.setPreferredLayers({ spatialLayer: networkQuality }); }); } diff --git a/apps/media/src/record/record.service.ts b/apps/media/src/record/record.service.ts index 6873d333..e29a1b26 100644 --- a/apps/media/src/record/record.service.ts +++ b/apps/media/src/record/record.service.ts @@ -66,10 +66,6 @@ export class RecordService { return recordInfo; } - getRecordInfo(roomId: string) { - return this.recordInfos.get(roomId); - } - private async addPlainTransport(recordInfo: RecordInfo, router: types.Router) { const plainTransport = await this.mediasoupService.createPlainTransport(router); recordInfo.setPlainTransport(plainTransport); @@ -117,7 +113,7 @@ export class RecordService { return; } this.releasePort(recordInfo.port); - recordInfo.stopRecordProcess(); + recordInfo.clearStream(); this.recordInfos.delete(roomId); } diff --git a/apps/media/src/record/recordInfo.ts b/apps/media/src/record/recordInfo.ts index 62d2168c..0bc153a5 100644 --- a/apps/media/src/record/recordInfo.ts +++ b/apps/media/src/record/recordInfo.ts @@ -43,7 +43,7 @@ export class RecordInfo { this.recordConsumer.resume(); } - stopRecordProcess() { + clearStream() { if (this.recordConsumer) { this.recordConsumer.close(); this.recordConsumer = null; @@ -84,6 +84,7 @@ export class RecordInfo { this.ncpService.uploadFile(filePath, remoteFileName, roomId); unlinkSync(sdpFilePath); this.ffmpegProcess = null; + this.clearStream(); }) .save(filePath); diff --git a/apps/media/src/room/room.service.ts b/apps/media/src/room/room.service.ts index 848fc443..cecda26f 100644 --- a/apps/media/src/room/room.service.ts +++ b/apps/media/src/room/room.service.ts @@ -11,8 +11,8 @@ export class RoomService { constructor() {} - createRoom(roomId: string, router: Router) { - const room = new Room(roomId, router); + createRoom(roomId: string, router: Router, masterSocketId: string) { + const room = new Room(roomId, router, masterSocketId); this.rooms.set(roomId, room); return roomId; } @@ -31,14 +31,7 @@ export class RoomService { deletePeer(socketId: string) { for (const [roomId, room] of this.rooms) { - if (!room.removePeer(socketId)) continue; - - if (room.peers.size === 0) { - room.close(); - this.rooms.delete(roomId); - } - - return roomId; + if (room.removePeer(socketId)) return roomId; } } @@ -48,4 +41,28 @@ export class RoomService { this.rooms.delete(roomId); return roomId; } + + checkIsMaster(roomId: string, socketId: string) { + const room = this.rooms.get(roomId); + if (!room) { + return false; + } + return room.masterSocketId === socketId; + } + + checkRoomIsOpen(roomId: string) { + const room = this.rooms.get(roomId); + if (!room) { + return false; + } + return room.isOpen; + } + + setRoomIsOpen(roomId: string, isOpen: boolean) { + const room = this.rooms.get(roomId); + if (!room) { + return; + } + room.isOpen = isOpen; + } } diff --git a/apps/media/src/room/room.ts b/apps/media/src/room/room.ts index e2485668..224941cc 100644 --- a/apps/media/src/room/room.ts +++ b/apps/media/src/room/room.ts @@ -6,13 +6,17 @@ import { Peer } from './peer'; export class Room { id: string; + masterSocketId: string; router: Router; peers: Map; + isOpen: boolean; - constructor(roomId: string, router: Router) { + constructor(roomId: string, router: Router, masterSocketId: string) { this.id = roomId; this.router = router; + this.masterSocketId = masterSocketId; this.peers = new Map(); + this.isOpen = true; } getRouter() { diff --git a/apps/media/src/signaling/signaling.gateway.ts b/apps/media/src/signaling/signaling.gateway.ts index 047bbd49..89328120 100644 --- a/apps/media/src/signaling/signaling.gateway.ts +++ b/apps/media/src/signaling/signaling.gateway.ts @@ -13,6 +13,7 @@ import type { client, server } from '@repo/mediasoup'; import { MediasoupService } from '@/mediasoup/mediasoup.service'; import { RecordService } from '@/record/record.service'; +import { RoomService } from '@/room/room.service'; import { WSExceptionFilter } from '@/wsException.filter'; @WebSocketGateway() @@ -20,20 +21,21 @@ import { WSExceptionFilter } from '@/wsException.filter'; export class SignalingGateway implements OnGatewayDisconnect { constructor( private mediasoupService: MediasoupService, - private recordService: RecordService + private recordService: RecordService, + private roomService: RoomService ) {} @SubscribeMessage(SOCKET_EVENTS.createRoom) - async handleCreateRoom(@MessageBody('roomId') roomId: string) { - await this.mediasoupService.createRoom(roomId); + async handleCreateRoom(@ConnectedSocket() client: Socket, @MessageBody('roomId') roomId: string) { + await this.mediasoupService.createRoom(roomId, client.id); return { roomId }; } @SubscribeMessage(SOCKET_EVENTS.joinRoom) joinRoom(@ConnectedSocket() client: Socket, @MessageBody() joinRoomDto: server.JoinRoomDto) { const { roomId, nickname } = joinRoomDto; - client.join(roomId); const rtpCapabilities = this.mediasoupService.joinRoom(roomId, client.id, nickname); + client.join(roomId); client.to(roomId).emit(SOCKET_EVENTS.newPeer, { peerId: client.id, nickname }); return { rtpCapabilities }; } @@ -104,12 +106,18 @@ export class SignalingGateway implements OnGatewayDisconnect { handleDisconnect(@ConnectedSocket() client: Socket) { const roomId = this.mediasoupService.disconnect(client.id); - const recordInfo = this.recordService.getRecordInfo(roomId); - if (recordInfo && recordInfo.socketId === client.id) { + const isMaster = this.roomService.checkIsMaster(roomId, client.id); + if (isMaster) { + client.to(roomId).emit(SOCKET_EVENTS.roomClosed); this.recordService.stopRecord(roomId); + this.mediasoupService.closeRoom(roomId); + return; } - client.to(roomId).emit(SOCKET_EVENTS.peerLeft, { peerId: client.id }); + const isOpen = this.roomService.checkRoomIsOpen(roomId); + if (isOpen) { + client.to(roomId).emit(SOCKET_EVENTS.peerLeft, { peerId: client.id }); + } } @SubscribeMessage(SOCKET_EVENTS.closeProducer) @@ -188,7 +196,7 @@ export class SignalingGateway implements OnGatewayDisconnect { @SubscribeMessage(SOCKET_EVENTS.closeRoom) closeMeetingRoom(@ConnectedSocket() client: Socket, @MessageBody('roomId') roomId: string) { client.to(roomId).emit(SOCKET_EVENTS.roomClosed); - this.mediasoupService.closeRoom(roomId); + this.roomService.setRoomIsOpen(roomId, false); } @SubscribeMessage(SOCKET_EVENTS.startRecord) diff --git a/apps/media/src/signaling/signaling.module.ts b/apps/media/src/signaling/signaling.module.ts index a3bf7294..82c90837 100644 --- a/apps/media/src/signaling/signaling.module.ts +++ b/apps/media/src/signaling/signaling.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { MediasoupModule } from '@/mediasoup/mediasoup.module'; import { RecordModule } from '@/record/record.module'; +import { RoomModule } from '@/room/room.module'; import { SignalingGateway } from './signaling.gateway'; @Module({ - imports: [MediasoupModule, RecordModule], + imports: [MediasoupModule, RecordModule, RoomModule], providers: [SignalingGateway], exports: [SignalingGateway], }) diff --git a/apps/web/src/components/NotFound.tsx b/apps/web/src/components/NotFound.tsx new file mode 100644 index 00000000..071993cf --- /dev/null +++ b/apps/web/src/components/NotFound.tsx @@ -0,0 +1,20 @@ +import { Link } from '@tanstack/react-router'; + +import TicleCharacterBadge from '@/assets/images/ticle-character-badge.png'; +import TicleLogo from '@/assets/ticle.svg?react'; +import Button from '@/components/common/Button'; + +function NotFound() { + return ( +
+ 티클 캐릭터 + +

페이지가 존재하지 않습니다

+ + + +
+ ); +} + +export default NotFound; diff --git a/apps/web/src/components/NotSupportedMobile.tsx b/apps/web/src/components/NotSupportedMobile.tsx new file mode 100644 index 00000000..73c66030 --- /dev/null +++ b/apps/web/src/components/NotSupportedMobile.tsx @@ -0,0 +1,15 @@ +import TicleCharacterBadge from '@/assets/images/ticle-character-badge.png'; +import TicleLogo from '@/assets/ticle.svg?react'; + +function NotSupportedMobile() { + return ( +
+ 티클 캐릭터 + +

모바일 환경은 지원하지 않습니다

+

데스크톱 브라우저에서 접속해주세요.

+
+ ); +} + +export default NotSupportedMobile; diff --git a/apps/web/src/components/dashboard/AiSummaryDialog.tsx b/apps/web/src/components/dashboard/AiSummaryDialog.tsx index 70573089..37681f3d 100644 --- a/apps/web/src/components/dashboard/AiSummaryDialog.tsx +++ b/apps/web/src/components/dashboard/AiSummaryDialog.tsx @@ -13,11 +13,11 @@ function AiSummaryDialog({ isOpen, onClose, ticleId }: AiSummaryDialogProps) { const { data } = useAiSummary(ticleId); return ( - + AI 음성 요약 - - {!data && ( + + {!data?.summaryText && (
@@ -25,15 +25,8 @@ function AiSummaryDialog({ isOpen, onClose, ticleId }: AiSummaryDialogProps) {
)} - {data && !data.summaryText && ( -
- - AI 요약 결과가 없어요. - -
- )} {data && data.summaryText && ( -

{data.summaryText}

+

{data.summaryText}

)}
diff --git a/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx b/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx index 7371fe80..97fc140b 100644 --- a/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx +++ b/apps/web/src/components/dashboard/apply/TicleInfoCard.tsx @@ -15,6 +15,7 @@ interface TicleInfoCardProps { startTime: string; endTime: string; status: 'closed' | 'open' | 'inProgress'; + isSummaryExist: boolean; } function TicleInfoCard({ @@ -24,6 +25,7 @@ function TicleInfoCard({ startTime, endTime, status, + isSummaryExist, }: TicleInfoCardProps) { const { isOpen, onOpen, onClose } = useModal(); const { dateStr, timeRangeStr } = formatDateTimeRange(startTime, endTime); @@ -59,7 +61,7 @@ function TicleInfoCard({
- {status === 'closed' && ( + {status === 'closed' && isSummaryExist && (
- {status === 'closed' && ( + {status === 'closed' && isSummaryExist && (