From 1380c2414e3c5db2fa4b7ad0a79d693cc520c3f4 Mon Sep 17 00:00:00 2001 From: Guijung Woo Date: Wed, 1 May 2024 16:42:50 +0900 Subject: [PATCH] [FEATURE] complete `/sample` endpoint for `vr-resources`, `vr-videos` (#16, #10) * 1. fix: add entities `isSample` property * 2. fix: `sample-vr-resource` * 2. fix: trivial swagger * 3. fix: (trivial) vr-video api deprecated * 3. feat: `sample-vr-video` * 4. refactor: move sample services logic to origin domain directory. --- src/app.module.ts | 3 +- .../sample-generate-video.request.dto.ts | 10 + .../sample-get-vr-resource.request.dto.ts | 1 - .../sample-get-vr-video.request.dto.ts | 9 + .../sample-get-vr-resources.response.dto.ts | 43 --- .../entity/sample-vr-resource.entity.ts | 63 ----- .../sample-vr-resource.repository.ts | 23 -- src/domain/sample/sample.controller.ts | 52 +++- src/domain/sample/sample.module.ts | 16 +- .../service/sample-vr-resource.service.ts | 119 -------- .../request/generate-avatar.request.dto.ts | 1 - .../response/get-vr-resources.response.dto.ts | 15 +- .../vr-resource/entity/vr-resource.entity.ts | 6 + .../repository/vr-resource.repository.ts | 6 + .../service/vr-resource-queue.service.ts | 79 ++++++ .../service/vr-resource.service.ts | 28 +- .../vr-resource/vr-resource.controller.ts | 6 +- src/domain/vr-resource/vr-resource.module.ts | 3 +- .../response/get-vr-videos.response.dto.ts | 3 + src/domain/vr-video/entity/vr-video.entity.ts | 6 + .../repository/vr-video.repository.ts | 24 ++ .../vr-video/service/vr-video.service.ts | 267 ++++++++++++------ src/domain/vr-video/vr-video.controller.ts | 29 +- src/domain/vr-video/vr-video.module.ts | 1 + 24 files changed, 414 insertions(+), 399 deletions(-) create mode 100644 src/domain/sample/dto/request/sample-generate-video.request.dto.ts create mode 100644 src/domain/sample/dto/request/sample-get-vr-video.request.dto.ts delete mode 100644 src/domain/sample/dto/response/sample-get-vr-resources.response.dto.ts delete mode 100644 src/domain/sample/entity/sample-vr-resource.entity.ts delete mode 100644 src/domain/sample/repository/sample-vr-resource.repository.ts delete mode 100644 src/domain/sample/service/sample-vr-resource.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 8040da5..8b4834c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,7 +17,6 @@ import { VrVideo } from './domain/vr-video/entity/vr-video.entity'; import { VrVideoModule } from './domain/vr-video/vr-video.module'; import { AppController } from './app.controller'; import { SampleModule } from './domain/sample/sample.module'; -import { SampleVrResource } from './domain/sample/entity/sample-vr-resource.entity'; @Module({ imports: [ @@ -35,7 +34,7 @@ import { SampleVrResource } from './domain/sample/entity/sample-vr-resource.enti username: configService.get('DB_USERNAME'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_DATABASE'), - entities: [User, Group, Badge, VrResource, VrVideo, SampleVrResource], + entities: [User, Group, Badge, VrResource, VrVideo], migrations: [__dirname + '/src/migrations/*.ts'], autoLoadEntities: true, charset: 'utf8mb4', diff --git a/src/domain/sample/dto/request/sample-generate-video.request.dto.ts b/src/domain/sample/dto/request/sample-generate-video.request.dto.ts new file mode 100644 index 0000000..126758a --- /dev/null +++ b/src/domain/sample/dto/request/sample-generate-video.request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { GenerateVrVideoRequestDto } from 'src/domain/vr-video/dto/request/generate-vr-video.request.dto'; + +export class SampleGenerateVideoRequestDto extends GenerateVrVideoRequestDto { + @ApiProperty({ description: 'admin임을 인증하는 키', example: 'key' }) + @IsString() + @IsNotEmpty() + key: string; +} diff --git a/src/domain/sample/dto/request/sample-get-vr-resource.request.dto.ts b/src/domain/sample/dto/request/sample-get-vr-resource.request.dto.ts index a87a216..39bafe5 100644 --- a/src/domain/sample/dto/request/sample-get-vr-resource.request.dto.ts +++ b/src/domain/sample/dto/request/sample-get-vr-resource.request.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; -import { GenerateSceneRequestDto } from 'src/domain/vr-resource/dto/request/generate-scene.request.dto'; export class SampleGetVrResourcesRequestDto { @ApiProperty({ description: 'admin임을 인증하는 키', example: 'key' }) diff --git a/src/domain/sample/dto/request/sample-get-vr-video.request.dto.ts b/src/domain/sample/dto/request/sample-get-vr-video.request.dto.ts new file mode 100644 index 0000000..7dd2d47 --- /dev/null +++ b/src/domain/sample/dto/request/sample-get-vr-video.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SampleGetVrVideosRequestDto { + @ApiProperty({ description: 'admin임을 인증하는 키', example: 'key' }) + @IsString() + @IsNotEmpty() + key: string; +} diff --git a/src/domain/sample/dto/response/sample-get-vr-resources.response.dto.ts b/src/domain/sample/dto/response/sample-get-vr-resources.response.dto.ts deleted file mode 100644 index e1da897..0000000 --- a/src/domain/sample/dto/response/sample-get-vr-resources.response.dto.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; -import { SampleVrResource } from '../../entity/sample-vr-resource.entity'; - -export class SampleVrResourceDto extends PickType(SampleVrResource, [ - 'id', - 'title', - 'type', - 'createdAt', -] as const) { - @ApiProperty({ - description: '인증된 storage URL (10분 간 유효)', - example: [ - 'https://storage.googleapis.com/...', - 'https://storage.googleapis.com/...', - ], - }) - storageUrls: string[]; - - static of( - vrResource: SampleVrResource, - storageUrls: string[], - ): SampleVrResourceDto { - return { - id: vrResource.id, - title: vrResource.title, - type: vrResource.type, - storageUrls: storageUrls, - createdAt: vrResource.createdAt, - }; - } -} - -export class SampleGetVrResourcesResponseDto { - @ApiProperty({ - description: 'VR 자원(아바타, 배경) 목록', - type: [SampleVrResourceDto], - }) - vrResources: SampleVrResourceDto[]; - - constructor(vrResources: SampleVrResourceDto[]) { - this.vrResources = vrResources; - } -} diff --git a/src/domain/sample/entity/sample-vr-resource.entity.ts b/src/domain/sample/entity/sample-vr-resource.entity.ts deleted file mode 100644 index 3429509..0000000 --- a/src/domain/sample/entity/sample-vr-resource.entity.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Group } from 'src/domain/group/entity/group.entity'; -import { VrVideo } from 'src/domain/vr-video/entity/vr-video.entity'; -import { - Entity, - CreateDateColumn, - UpdateDateColumn, - DeleteDateColumn, - Column, - ManyToOne, - PrimaryColumn, - OneToMany, - ManyToMany, -} from 'typeorm'; - -@Entity({ schema: 'remember_me', name: 'sample_vr_resource' }) -export class SampleVrResource { - @ApiProperty({ - description: - 'VR 리소스 아이디(로컬에서 다운로드 받을 때, 중복검사 등으로 이용가능)', - example: '123', - }) - @PrimaryColumn() - id: string; - - @ApiProperty({ description: 'VR 리소스 제목', example: '아들 아바타' }) - @Column() - title: string; - - @ApiProperty({ - description: 'VR 리소스 위치 (Cloud Storage 내의 폴더 위치, 파일 아님.)', - example: 'to/file/', - }) - @Column() - filePath: string; - - @ApiProperty({ - description: '타입 (아바타, 배경)', - enum: ['avatar', 'scene'], - example: 'avatar', - }) - @Column() - type: 'avatar' | 'scene'; - - @ApiProperty({ - description: 'VR 리소스 생성일', - example: '2021-09-23T00:00:00.000Z', - }) - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; - - @DeleteDateColumn() - deletedAt: Date | null; - - @OneToMany(() => VrVideo, (vrVideo) => vrVideo.scene) - vrVideosAsScene: VrVideo[]; - - @ManyToMany(() => VrVideo, (vrVideo) => vrVideo.avatars) - vrVideosAsAvatar: VrVideo[]; -} diff --git a/src/domain/sample/repository/sample-vr-resource.repository.ts b/src/domain/sample/repository/sample-vr-resource.repository.ts deleted file mode 100644 index 130f121..0000000 --- a/src/domain/sample/repository/sample-vr-resource.repository.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Repository } from 'typeorm'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Injectable } from '@nestjs/common'; -import { SampleVrResource } from '../entity/sample-vr-resource.entity'; - -@Injectable() -export class SampleVrResourceRepository extends Repository { - constructor( - @InjectRepository(SampleVrResource) - private readonly repository: Repository, - ) { - super(repository.target, repository.manager); - } - - /** get all `sample vr resource` */ - async find(): Promise { - return this.repository.find(); - } - - async findById(id: string): Promise { - return this.repository.findOne({ where: { id } }); - } -} diff --git a/src/domain/sample/sample.controller.ts b/src/domain/sample/sample.controller.ts index 75b55c9..7036748 100644 --- a/src/domain/sample/sample.controller.ts +++ b/src/domain/sample/sample.controller.ts @@ -15,9 +15,9 @@ import { ApiBody, ApiConsumes, ApiOperation, + ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { SampleVrResourceService } from './service/sample-vr-resource.service'; import { SampleGenerateSceneRequestDto } from './dto/request/sample-generate-scene.request.dto'; import { FileFieldsInterceptor, @@ -25,8 +25,14 @@ import { } from '@nestjs/platform-express'; import { SampleGenerateAvatarRequestDto } from './dto/request/sample-generate-avatar.request.dto'; import { SampleGetVrResourcesRequestDto } from './dto/request/sample-get-vr-resource.request.dto'; -import { SampleGetVrResourcesResponseDto } from './dto/response/sample-get-vr-resources.response.dto'; import { ConfigService } from '@nestjs/config'; +import { SampleGetVrVideosRequestDto } from './dto/request/sample-get-vr-video.request.dto'; +import { GetVrVideosResponseDto } from '../vr-video/dto/response/get-vr-videos.response.dto'; +import { GetVrResourcesResponseDto } from '../vr-resource/dto/response/get-vr-resources.response.dto'; +import { VrVideoService } from '../vr-video/service/vr-video.service'; +import { SampleGenerateVideoRequestDto } from './dto/request/sample-generate-video.request.dto'; +import { VrResourceService } from '../vr-resource/service/vr-resource.service'; +import { VrResourceQueueService } from '../vr-resource/service/vr-resource-queue.service'; @ApiTags('Sample') @Controller('/sample') @@ -34,8 +40,10 @@ export class SampleController { private adminKey: string; constructor( - private readonly sampleVrResourceService: SampleVrResourceService, + private readonly vrResourceService: VrResourceService, + private readonly vrResourceQueueService: VrResourceQueueService, private readonly configService: ConfigService, + private readonly vrVideoService: VrVideoService, ) { this.adminKey = this.configService.get('ADMIN_KEY'); } @@ -50,7 +58,7 @@ export class SampleController { @Body() requestDto: SampleGenerateSceneRequestDto, ) { this.validateAdminKey(requestDto.key); - return this.sampleVrResourceService.generateScene(requestDto, video); + return this.vrResourceQueueService.generateSampleScene(requestDto, video); } @ApiOperation({ summary: '아바타 생성 요청' }) @@ -69,24 +77,48 @@ export class SampleController { @Body() requestDto: SampleGenerateAvatarRequestDto, ) { this.validateAdminKey(requestDto.key); - return this.sampleVrResourceService.generateAvatar( + return this.vrResourceQueueService.generateSampleAvatar( requestDto, files.face[0], files.body[0], ); } - @ApiOperation({ summary: '완성된 VR 자원(배경, 아바타) 불러오기' }) - @Get('/') + @ApiOperation({ summary: '샘플 VR 자원 찾기' }) + @ApiResponse({ + status: 200, + type: GetVrResourcesResponseDto, + }) + @Get('/vr-resource') async getVrResources( @Body() requestDto: SampleGetVrResourcesRequestDto, - ): Promise { + ): Promise { this.validateAdminKey(requestDto.key); - return new SampleGetVrResourcesResponseDto( - await this.sampleVrResourceService.getVrResources(), + return new GetVrResourcesResponseDto( + await this.vrResourceService.getSampleVrResources(), ); } + /* -------------------------------------------------------------------------- */ + + @ApiOperation({ summary: '샘플 VR 비디오 찾기' }) + @ApiResponse({ type: GetVrVideosResponseDto }) + @Get('/vr-video') + async getSampleVrVideos( + @Body() requestDto: SampleGetVrVideosRequestDto, + ): Promise { + this.validateAdminKey(requestDto.key); + return this.vrVideoService.getSampleVrVideos(); + } + + @ApiOperation({ summary: '샘플 VR 비디오 만들기' }) + @Post('/vr-video') + async getVrVideos(@Body() requestDto: SampleGenerateVideoRequestDto) { + this.validateAdminKey(requestDto.key); + return this.vrVideoService.generateSampleVrVideo(requestDto, true); + } + + /* -------------------------------------------------------------------------- */ // ! TODO: make as decorator validateAdminKey(key: string) { if (key !== this.adminKey) { diff --git a/src/domain/sample/sample.module.ts b/src/domain/sample/sample.module.ts index 980ac45..03a6f71 100644 --- a/src/domain/sample/sample.module.ts +++ b/src/domain/sample/sample.module.ts @@ -2,14 +2,18 @@ import { Module } from '@nestjs/common'; import { GcpModule } from 'src/common/gcp/gcp.module'; import { SampleController } from './sample.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { SampleVrResourceService } from './service/sample-vr-resource.service'; -import { SampleVrResourceRepository } from './repository/sample-vr-resource.repository'; -import { SampleVrResource } from './entity/sample-vr-resource.entity'; +import { VrResource } from '../vr-resource/entity/vr-resource.entity'; +import { VrResourceModule } from '../vr-resource/vr-resource.module'; +import { VrVideo } from '../vr-video/entity/vr-video.entity'; +import { VrVideoModule } from '../vr-video/vr-video.module'; @Module({ - imports: [TypeOrmModule.forFeature([SampleVrResource]), GcpModule], + imports: [ + TypeOrmModule.forFeature([VrResource, VrVideo]), + VrResourceModule, + VrVideoModule, + GcpModule, + ], controllers: [SampleController], - providers: [SampleVrResourceService, SampleVrResourceRepository], - exports: [SampleVrResourceService], }) export class SampleModule {} diff --git a/src/domain/sample/service/sample-vr-resource.service.ts b/src/domain/sample/service/sample-vr-resource.service.ts deleted file mode 100644 index a25e581..0000000 --- a/src/domain/sample/service/sample-vr-resource.service.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { createHash } from 'crypto'; -import { VrResourceStorageRepository } from 'src/common/gcp/cloud-storage/vr-resource-storage.repository'; -import { SampleAiTaskRequestRepository } from '../../../common/gcp/firestore/repository/sample-ai-task-request.repository'; -import { SampleAiTaskRequest } from '../../../common/gcp/firestore/document/sample-ai-task-request.document'; -import { CloudFunctionsRepository } from 'src/common/gcp/cloud-functions/cloud-functions.repository'; -import { SampleGenerateSceneRequestDto } from '../dto/request/sample-generate-scene.request.dto'; -import { SampleGenerateAvatarRequestDto } from '../dto/request/sample-generate-avatar.request.dto'; - -// NOTE: sample uses the same queue with normal request -import { AiTaskQueueRepository } from '../../../common/gcp/memorystore/ai-task-queue.repository'; -import { SampleVrResourceDto } from '../dto/response/sample-get-vr-resources.response.dto'; -import { SampleVrResourceRepository } from '../repository/sample-vr-resource.repository'; - -@Injectable() -export class SampleVrResourceService { - constructor( - // sample has difference in database entity. - private readonly vrResourceRepository: SampleVrResourceRepository, - private readonly aiTaskRequestRepository: SampleAiTaskRequestRepository, - - // but, shared queue, storage, etc... - private readonly vrResourceStorageRepository: VrResourceStorageRepository, - private readonly aiTaskQueueRepository: AiTaskQueueRepository, - private readonly cloudFunctionsRepository: CloudFunctionsRepository, - ) {} - - async generateScene( - requestDto: SampleGenerateSceneRequestDto, - video: Express.Multer.File, - ): Promise { - const { title, location } = requestDto; - const requestId = this.generateRequestId(); - - // 1. Store face source to GCP Cloud Storage. - const sceneVideoPath = `3dgs-request/scene/${requestId}/video`; - await this.vrResourceStorageRepository.uploadFile(video, sceneVideoPath); - - // 2. Store request data to Firestore. - const task: SampleAiTaskRequest = { - // necessary - id: requestId, - title: title, - status: 'pending', - createdAt: new Date(), - // scene - type: 'scene', - location: location, - sceneVideoPath: sceneVideoPath, - }; - await this.aiTaskRequestRepository.addTask(requestId, task); - - // 3. Store taskId to Redis Queue. - await this.aiTaskQueueRepository.queueRequest(requestId); - - // 4. Trigger GCP Cloud Functions. - await this.cloudFunctionsRepository.triggerAiScheduler(); - return; - } - - async generateAvatar( - requestDto: SampleGenerateAvatarRequestDto, - face: Express.Multer.File, - body: Express.Multer.File, - ): Promise { - const { title, gender } = requestDto; - const requestId = this.generateRequestId(); - - // 1. Store file source to GCP Cloud Storage. - const faceFilePath = `3dgs-request/avatar/${requestId}/body`; - await this.vrResourceStorageRepository.uploadFile(body, faceFilePath); - const bodyImagePath = `3dgs-request/avatar/${requestId}/face`; - await this.vrResourceStorageRepository.uploadFile(face, bodyImagePath); - - // 2. Store request data to Firestore. - const task: SampleAiTaskRequest = { - // necessary - id: requestId, - title: title, - status: 'pending', - createdAt: new Date(), - // avatar - type: 'avatar', - bodyImagePath: bodyImagePath, - faceImagePath: faceFilePath, - gender: gender, - }; - await this.aiTaskRequestRepository.addTask(requestId, task); - - // 3. Store taskId to Redis Queue. - await this.aiTaskQueueRepository.queueRequest(requestId); - - // 4. Trigger GCP Cloud Functions. - await this.cloudFunctionsRepository.triggerAiScheduler(); - return; - } - - async getVrResources(): Promise { - const vrResources = await this.vrResourceRepository.find(); - const vrResourceDtos = await Promise.all( - vrResources.map(async (vrResource) => { - const storageUrls = - await this.vrResourceStorageRepository.generateSignedUrlList( - vrResource.filePath, - ); - return SampleVrResourceDto.of(vrResource, storageUrls); - }), - ); - - return vrResourceDtos; - } - - private generateRequestId(): string { - const currentTime = Date.now().toString(); - const data = `${currentTime}`; - const hash = createHash('sha256').update(data).digest('hex'); - return hash; - } -} diff --git a/src/domain/vr-resource/dto/request/generate-avatar.request.dto.ts b/src/domain/vr-resource/dto/request/generate-avatar.request.dto.ts index c43500e..24a9a70 100644 --- a/src/domain/vr-resource/dto/request/generate-avatar.request.dto.ts +++ b/src/domain/vr-resource/dto/request/generate-avatar.request.dto.ts @@ -1,4 +1,3 @@ -import { BadRequestException } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; diff --git a/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts b/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts index 1242b93..4c4b9b1 100644 --- a/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts +++ b/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts @@ -1,12 +1,12 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { VrResource } from '../../entity/vr-resource.entity'; -import { SampleVrResourceDto } from 'src/domain/sample/dto/response/sample-get-vr-resources.response.dto'; export class VrResourceDto extends PickType(VrResource, [ 'id', 'title', 'type', 'createdAt', + 'isSample', ] as const) { @ApiProperty({ description: @@ -25,6 +25,7 @@ export class VrResourceDto extends PickType(VrResource, [ type: vrResource.type, storageUrls: storageUrls, createdAt: vrResource.createdAt, + isSample: vrResource.isSample, }; } } @@ -36,17 +37,7 @@ export class GetVrResourcesResponseDto { }) vrResources: VrResourceDto[]; - @ApiProperty({ - description: '샘플 VR자원 목록', - type: [SampleVrResourceDto], - }) - sampleVrResources: SampleVrResourceDto[]; - - constructor( - vrResources: VrResourceDto[], - sampleVrResources: SampleVrResourceDto[], - ) { + constructor(vrResources: VrResourceDto[]) { this.vrResources = vrResources; - this.sampleVrResources = sampleVrResources; } } diff --git a/src/domain/vr-resource/entity/vr-resource.entity.ts b/src/domain/vr-resource/entity/vr-resource.entity.ts index 179bce8..a872a89 100644 --- a/src/domain/vr-resource/entity/vr-resource.entity.ts +++ b/src/domain/vr-resource/entity/vr-resource.entity.ts @@ -42,6 +42,12 @@ export class VrResource { @Column() type: 'avatar' | 'scene'; + @ApiProperty({ + description: '샘플 여부', + example: false, + }) + isSample: boolean; + @ApiProperty({ description: 'VR 리소스 생성일', example: '2021-09-23T00:00:00.000Z', diff --git a/src/domain/vr-resource/repository/vr-resource.repository.ts b/src/domain/vr-resource/repository/vr-resource.repository.ts index b1f46ef..4ca0485 100644 --- a/src/domain/vr-resource/repository/vr-resource.repository.ts +++ b/src/domain/vr-resource/repository/vr-resource.repository.ts @@ -12,6 +12,12 @@ export class VrResourceRepository extends Repository { super(repository.target, repository.manager); } + async findSamples(): Promise { + return this.repository.find({ + where: { isSample: true }, + }); + } + async findByGroupId(groupId: string): Promise { return this.repository.find({ where: { group: { id: groupId } }, diff --git a/src/domain/vr-resource/service/vr-resource-queue.service.ts b/src/domain/vr-resource/service/vr-resource-queue.service.ts index c6792cc..6546e08 100644 --- a/src/domain/vr-resource/service/vr-resource-queue.service.ts +++ b/src/domain/vr-resource/service/vr-resource-queue.service.ts @@ -9,6 +9,10 @@ import { AiTaskQueueRepository } from '../../../common/gcp/memorystore/ai-task-q import { CloudFunctionsRepository } from 'src/common/gcp/cloud-functions/cloud-functions.repository'; import { GenerateSceneRequestDto } from '../dto/request/generate-scene.request.dto'; import { GenerateAvatarRequestDto } from '../dto/request/generate-avatar.request.dto'; +import { SampleGenerateSceneRequestDto } from 'src/domain/sample/dto/request/sample-generate-scene.request.dto'; +import { SampleGenerateAvatarRequestDto } from 'src/domain/sample/dto/request/sample-generate-avatar.request.dto'; +import { SampleAiTaskRequest } from 'src/common/gcp/firestore/document/sample-ai-task-request.document'; +import { SampleAiTaskRequestRepository } from 'src/common/gcp/firestore/repository/sample-ai-task-request.repository'; @Injectable() export class VrResourceQueueService { @@ -18,6 +22,7 @@ export class VrResourceQueueService { private readonly groupService: GroupService, private readonly aiTaskQueueRepository: AiTaskQueueRepository, private readonly cloudFunctionsRepository: CloudFunctionsRepository, + private readonly sampleAiTaskRequestRepository: SampleAiTaskRequestRepository, ) {} async generateScene( @@ -103,6 +108,80 @@ export class VrResourceQueueService { return await this.aiTaskRequestRepository.getQueuedTasksByGroupId(groupId); } + /* --------------------------------- SAMPLE --------------------------------- */ + + async generateSampleScene( + requestDto: SampleGenerateSceneRequestDto, + video: Express.Multer.File, + ): Promise { + const { title, location, key } = requestDto; + const requestId = this.generateRequestId(key); + + // 1. Store source to GCP Cloud Storage. + const sceneVideoPath = `3dgs-request/scene/${requestId}/video`; + await this.vrResourceStorageRepository.uploadFile(video, sceneVideoPath); + + // 2. Store request data to Firestore. + const task: SampleAiTaskRequest = { + // necessary + id: requestId, + title: title, + status: 'pending', + createdAt: new Date(), + // scene + type: 'scene', + location: location, + sceneVideoPath: sceneVideoPath, + }; + await this.sampleAiTaskRequestRepository.addTask(requestId, task); + + // 3. Store taskId to Redis Queue. + await this.aiTaskQueueRepository.queueRequest(requestId); + + // 4. Trigger GCP Cloud Functions. + await this.cloudFunctionsRepository.triggerAiScheduler(); + return; + } + + async generateSampleAvatar( + requestDto: SampleGenerateAvatarRequestDto, + face: Express.Multer.File, + body: Express.Multer.File, + ): Promise { + const { title, gender, key } = requestDto; + const requestId = this.generateRequestId(key); + + // 1. Store file source to GCP Cloud Storage. + const faceFilePath = `3dgs-request/avatar/${requestId}/body`; + await this.vrResourceStorageRepository.uploadFile(body, faceFilePath); + const bodyImagePath = `3dgs-request/avatar/${requestId}/face`; + await this.vrResourceStorageRepository.uploadFile(face, bodyImagePath); + + // 2. Store request data to Firestore. + const task: SampleAiTaskRequest = { + // necessary + id: requestId, + title: title, + status: 'pending', + createdAt: new Date(), + // avatar + type: 'avatar', + bodyImagePath: bodyImagePath, + faceImagePath: faceFilePath, + gender: gender, + }; + await this.sampleAiTaskRequestRepository.addTask(requestId, task); + + // 3. Store taskId to Redis Queue. + await this.aiTaskQueueRepository.queueRequest(requestId); + + // 4. Trigger GCP Cloud Functions. + await this.cloudFunctionsRepository.triggerAiScheduler(); + return; + } + + /* -------------------------------------------------------------------------- */ + private generateRequestId(userId: string): string { const currentTime = Date.now().toString(); const data = `${currentTime}-${userId}`; diff --git a/src/domain/vr-resource/service/vr-resource.service.ts b/src/domain/vr-resource/service/vr-resource.service.ts index c61d7f8..265eb5a 100644 --- a/src/domain/vr-resource/service/vr-resource.service.ts +++ b/src/domain/vr-resource/service/vr-resource.service.ts @@ -15,10 +15,32 @@ export class VrResourceService { ) {} async getVrResources(user: User): Promise { + // 1. Get VR resource (sample, real both) const groupId = (await this.groupService.getMyGroup(user)).id; - const vrResources = await this.vrResourceRepository.findByGroupId(groupId); - const vrResourceDtos = await Promise.all( + const sampleVrResources = await this.vrResourceRepository.findSamples(); + + // 2. make as dto. + const vrResourceDtos = await this.makeVrResourceDto( + vrResources.concat(sampleVrResources), + ); + return vrResourceDtos; + } + + async getSampleVrResources(): Promise { + // 1. Get VR resource (sample) + const sampleVrResources = await this.vrResourceRepository.findSamples(); + + // 2. make as dto. + const vrResourceDtos = await this.makeVrResourceDto(sampleVrResources); + return vrResourceDtos; + } + + /* -------------------------------------------------------------------------- */ + private makeVrResourceDto( + vrResources: VrResource[], + ): Promise { + return Promise.all( vrResources.map(async (vrResource) => { const storageUrls = await this.vrResourceStorageRepository.generateSignedUrlList( @@ -27,7 +49,5 @@ export class VrResourceService { return VrResourceDto.of(vrResource, storageUrls); }), ); - - return vrResourceDtos; } } diff --git a/src/domain/vr-resource/vr-resource.controller.ts b/src/domain/vr-resource/vr-resource.controller.ts index 7c5cb03..4a71c9f 100644 --- a/src/domain/vr-resource/vr-resource.controller.ts +++ b/src/domain/vr-resource/vr-resource.controller.ts @@ -31,7 +31,6 @@ import { GetAiTaskQueueResponseDto } from './dto/response/get-ai-task-queue.resp import { VrResourceService } from './service/vr-resource.service'; import { GetVrResourcesResponseDto } from './dto/response/get-vr-resources.response.dto'; import { GenerateAvatarRequestDto } from './dto/request/generate-avatar.request.dto'; -import { SampleVrResourceService } from '../sample/service/sample-vr-resource.service'; @ApiTags('VR-resource') @Controller('vr-resource') @@ -39,7 +38,6 @@ export class VrResourceController { constructor( private readonly vrResourceQueueService: VrResourceQueueService, private readonly vrResourceService: VrResourceService, - private readonly sampleVrResourceService: SampleVrResourceService, ) {} @ApiOperation({ @@ -100,9 +98,7 @@ export class VrResourceController { @AuthUser() user: User, ): Promise { const vrResourceDtos = await this.vrResourceService.getVrResources(user); - const sampleVrResourceDtos = - await this.sampleVrResourceService.getVrResources(); - return new GetVrResourcesResponseDto(vrResourceDtos, sampleVrResourceDtos); + return new GetVrResourcesResponseDto(vrResourceDtos); } @ApiOperation({ diff --git a/src/domain/vr-resource/vr-resource.module.ts b/src/domain/vr-resource/vr-resource.module.ts index 99fa085..d4c7393 100644 --- a/src/domain/vr-resource/vr-resource.module.ts +++ b/src/domain/vr-resource/vr-resource.module.ts @@ -18,11 +18,10 @@ import { User } from '../user/entity/user.entity'; imports: [ TypeOrmModule.forFeature([Group, User, VrResource]), GcpModule, - SampleModule, GroupModule, ], controllers: [VrResourceController], providers: [VrResourceQueueService, VrResourceService, VrResourceRepository], - exports: [VrResourceRepository], + exports: [VrResourceRepository, VrResourceService, VrResourceQueueService], }) export class VrResourceModule {} diff --git a/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts b/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts index 2a66262..635bf06 100644 --- a/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts +++ b/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts @@ -7,6 +7,7 @@ export class VrResourceDtoForVideo extends PickType(VrResource, [ 'title', 'type', 'createdAt', + 'isSample', ] as const) { // ! storageUrls: vr-resource(아바타) 파일 @ApiProperty({ @@ -36,12 +37,14 @@ export class VrResourceDtoForVideo extends PickType(VrResource, [ storageUrls: storageUrls, inVideoPositionFile: inVideoPositionFile, createdAt: vrResource.createdAt, + isSample: vrResource.isSample, }; } } export class GetVrVideosResponseDto extends PickType(VrVideo, [ 'title', + 'isSample', ] as const) { @ApiProperty({ type: VrResourceDtoForVideo }) scene: VrResourceDtoForVideo; diff --git a/src/domain/vr-video/entity/vr-video.entity.ts b/src/domain/vr-video/entity/vr-video.entity.ts index dc77e22..a2fda7a 100644 --- a/src/domain/vr-video/entity/vr-video.entity.ts +++ b/src/domain/vr-video/entity/vr-video.entity.ts @@ -27,6 +27,12 @@ export class VrVideo { @Column() title: string; + @ApiProperty({ + description: '샘플 여부', + example: false, + }) + isSample: boolean; + @ManyToOne(() => Group, (group) => group.badges, { onDelete: 'CASCADE' }) group: Group; diff --git a/src/domain/vr-video/repository/vr-video.repository.ts b/src/domain/vr-video/repository/vr-video.repository.ts index 79dac6c..ce84bd2 100644 --- a/src/domain/vr-video/repository/vr-video.repository.ts +++ b/src/domain/vr-video/repository/vr-video.repository.ts @@ -21,10 +21,34 @@ export class VrVideoRepository extends Repository { }); } + async createVrVideo( + id: string, + title: string, + scene: VrResource, + avatars: VrResource[], + isSample: boolean, + group?: Group, + ): Promise { + const vrVideo = new VrVideo(); + vrVideo.id = id; + vrVideo.title = title; + vrVideo.scene = scene; + vrVideo.avatars = avatars; + vrVideo.isSample = isSample; + if (group) vrVideo.group = group; + return this.repository.save(vrVideo); + } + async findById(videoId: string): Promise { return this.repository.findOne({ where: { id: videoId }, relations: ['scene', 'avatars'], }); } + + async findSamples(): Promise { + return this.repository.find({ + where: { isSample: true }, + }); + } } diff --git a/src/domain/vr-video/service/vr-video.service.ts b/src/domain/vr-video/service/vr-video.service.ts index ae58aa8..aa9204a 100644 --- a/src/domain/vr-video/service/vr-video.service.ts +++ b/src/domain/vr-video/service/vr-video.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { GenerateVrVideoRequestDto, VrResourceInfo, @@ -6,7 +10,6 @@ import { import { User } from 'src/domain/user/entity/user.entity'; import { GroupRepository } from 'src/domain/group/repository/group.repository'; import { VrVideoStorageRepository } from 'src/common/gcp/cloud-storage/vr-video-storage.repository'; -import { VrVideo } from '../entity/vr-video.entity'; import { createHash } from 'crypto'; import { VrResourceRepository } from 'src/domain/vr-resource/repository/vr-resource.repository'; import { ObjectDataType } from '../type/object-data.type'; @@ -17,6 +20,9 @@ import { VrResourceDtoForVideo, } from '../dto/response/get-vr-videos.response.dto'; import { VrResourceStorageRepository } from 'src/common/gcp/cloud-storage/vr-resource-storage.repository'; +import { VrVideo } from '../entity/vr-video.entity'; +import { SampleGetVrResourcesRequestDto } from 'src/domain/sample/dto/request/sample-get-vr-resource.request.dto'; +import { SampleGenerateVideoRequestDto } from 'src/domain/sample/dto/request/sample-generate-video.request.dto'; @Injectable() export class VrVideoService { @@ -29,60 +35,6 @@ export class VrVideoService { private readonly vrVideoRepository: VrVideoRepository, ) {} - /* ---------------------------- GET /vr-video/:id --------------------------- */ - - /** - * videoID를 통해 특정 video만 가져옴 - * @param user UserID - * @param videoId videoID - */ - async getVrVideo( - user: User, - videoId: string, - ): Promise { - const vrVideo = await this.vrVideoRepository.findById(videoId); - // 1. validation logic - if (!vrVideo) { - throw new NotFoundException('VrVideo not found'); - } - const isUserInGroup = await this.groupRepository.isUserInGroup( - user.id, - vrVideo.group.id, - ); - if (!isUserInGroup) { - throw new NotFoundException('User is not in the group'); - } - - // 2. return DTO from DB. - const sceneDto = VrResourceDtoForVideo.of( - vrVideo.scene, - await this.vrResourceStorageRepository.generateSignedUrlList( - vrVideo.scene.filePath, - ), - await this.getResourcePositionFileURL( - vrVideo.id, - vrVideo.scene.id, - 'scene', - ), - ); - const avatarDtos = await Promise.all( - vrVideo.avatars.map(async (avatar) => { - return VrResourceDtoForVideo.of( - avatar, - await this.vrResourceStorageRepository.generateSignedUrlList( - avatar.filePath, - ), - await this.getResourcePositionFileURL( - vrVideo.id, - avatar.id, - 'avatar', - ), - ); - }), - ); - return new GetVrVideosResponseDto(vrVideo, sceneDto, avatarDtos); - } - /* ------------------------------ GET /vr-video ----------------------------- */ /** @@ -90,42 +42,27 @@ export class VrVideoService { * @returns {GetVrVideosResponseDto[]} */ async getVrVideos(user: User): Promise { + // 1. group의 VR 비디오들을 가져옴. const groupId = (await this.groupService.getMyGroup(user)).id; const vrVideos = await this.vrVideoRepository.findByGroupIdWithResources( groupId, ); + // 2. 샘플 VR 비디오들을 가져옴. + const sampleVrVideos = await this.vrVideoRepository.findSamples(); + // 3. DTO 형식으로 return함. + return await this.makeVrVideoDto(vrVideos.concat(sampleVrVideos)); + } - return await Promise.all( - vrVideos.map(async (vrVideo) => { - const sceneDto = VrResourceDtoForVideo.of( - vrVideo.scene, - await this.vrResourceStorageRepository.generateSignedUrlList( - vrVideo.scene.filePath, - ), - await this.getResourcePositionFileURL( - vrVideo.id, - vrVideo.scene.id, - 'scene', - ), - ); - const avatarDtos = await Promise.all( - vrVideo.avatars.map(async (avatar) => { - return VrResourceDtoForVideo.of( - avatar, - await this.vrResourceStorageRepository.generateSignedUrlList( - avatar.filePath, - ), - await this.getResourcePositionFileURL( - vrVideo.id, - avatar.id, - 'avatar', - ), - ); - }), - ); - return new GetVrVideosResponseDto(vrVideo, sceneDto, avatarDtos); - }), - ); + /** + * @param user userID + * @returns {GetVrVideosResponseDto[]} + */ + async getSampleVrVideos(): Promise { + // 1. get relevant videos + const sampleVrVideos = await this.vrVideoRepository.findSamples(); + + // 2. make DTO + return await this.makeVrVideoDto(sampleVrVideos); } /* ----------------------------- POST /vr-video ----------------------------- */ @@ -141,6 +78,7 @@ export class VrVideoService { async generateVrVideo( user: User, requestDto: GenerateVrVideoRequestDto, + isSample: boolean, ): Promise { const group = await this.groupRepository.findByCareGiverId(user.id); const { title, sceneInfo, avatarsInfo } = requestDto; @@ -157,19 +95,69 @@ export class VrVideoService { return avatar; }), ); + + // security / validation check if (!scene || avatars.some((avatar) => !avatar)) { throw new NotFoundException('Resource not found'); } + if ( + (scene.group.id !== group.id && scene.isSample == false) || + avatars.some( + (avatar) => avatar.group.id !== group.id && avatar.isSample == false, + ) + ) { + throw new UnauthorizedException('Resource not found'); + } // Save VR Video to main DB. - const vrVideo = new VrVideo(); const vrVideoId = this.generateVrVideoId(user.id); - vrVideo.id = vrVideoId; - vrVideo.title = title; - vrVideo.scene = scene; - vrVideo.avatars = avatars; - vrVideo.group = group; - await this.vrVideoRepository.save(vrVideo); + await this.vrVideoRepository.createVrVideo( + vrVideoId, + title, + scene, + avatars, + isSample, + group, + ); + + await this.uploadVideoPositionToCloudStorage( + vrVideoId, + sceneInfo, + avatarsInfo, + ); + } + + async generateSampleVrVideo( + requestDto: SampleGenerateVideoRequestDto, + isSample: boolean, + ): Promise { + const { title, sceneInfo, avatarsInfo, key } = requestDto; + + // Get VR Resource From DB. + const scene = await this.vrResourceRepository.findById( + sceneInfo.resourceId, + ); + const avatars = await Promise.all( + avatarsInfo.map(async (avatarInfo) => { + const avatar = await this.vrResourceRepository.findById( + avatarInfo.resourceId, + ); + return avatar; + }), + ); + if (!scene || avatars.some((avatar) => !avatar)) { + throw new NotFoundException('Resource not found'); + } + + // Save VR Video to main DB. + const vrVideoId = this.generateVrVideoId(key); + await this.vrVideoRepository.createVrVideo( + vrVideoId, + title, + scene, + avatars, + isSample, + ); await this.uploadVideoPositionToCloudStorage( vrVideoId, @@ -243,4 +231,95 @@ export class VrVideoService { }), ); } + + private makeVrVideoDto( + vrVideos: VrVideo[], + ): Promise { + return Promise.all( + vrVideos.map(async (vrVideo) => { + const sceneDto = VrResourceDtoForVideo.of( + vrVideo.scene, + await this.vrResourceStorageRepository.generateSignedUrlList( + vrVideo.scene.filePath, + ), + await this.getResourcePositionFileURL( + vrVideo.id, + vrVideo.scene.id, + 'scene', + ), + ); + const avatarDtos = await Promise.all( + vrVideo.avatars.map(async (avatar) => { + return VrResourceDtoForVideo.of( + avatar, + await this.vrResourceStorageRepository.generateSignedUrlList( + avatar.filePath, + ), + await this.getResourcePositionFileURL( + vrVideo.id, + avatar.id, + 'avatar', + ), + ); + }), + ); + return new GetVrVideosResponseDto(vrVideo, sceneDto, avatarDtos); + }), + ); + } } + +// // deprecated +// // /* ---------------------------- GET /vr-video/:id --------------------------- */ + +// // /** +// // * videoID를 통해 특정 video만 가져옴 +// // * @param user UserID +// // * @param videoId videoID +// // */ +// // async getVrVideo( +// // user: User, +// // videoId: string, +// // ): Promise { +// // const vrVideo = await this.vrVideoRepository.findById(videoId); +// // // 1. validation logic +// // if (!vrVideo) { +// // throw new NotFoundException('VrVideo not found'); +// // } +// // const isUserInGroup = await this.groupRepository.isUserInGroup( +// // user.id, +// // vrVideo.group.id, +// // ); +// // if (!isUserInGroup) { +// // throw new NotFoundException('User is not in the group'); +// // } + +// // // 2. return DTO from DB. +// // const sceneDto = VrResourceDtoForVideo.of( +// // vrVideo.scene, +// // await this.vrResourceStorageRepository.generateSignedUrlList( +// // vrVideo.scene.filePath, +// // ), +// // await this.getResourcePositionFileURL( +// // vrVideo.id, +// // vrVideo.scene.id, +// // 'scene', +// // ), +// // ); +// // const avatarDtos = await Promise.all( +// // vrVideo.avatars.map(async (avatar) => { +// // return VrResourceDtoForVideo.of( +// // avatar, +// // await this.vrResourceStorageRepository.generateSignedUrlList( +// // avatar.filePath, +// // ), +// // await this.getResourcePositionFileURL( +// // vrVideo.id, +// // avatar.id, +// // 'avatar', +// // ), +// // ); +// // }), +// // ); +// // return new GetVrVideosResponseDto(vrVideo, sceneDto, avatarDtos); +// // } diff --git a/src/domain/vr-video/vr-video.controller.ts b/src/domain/vr-video/vr-video.controller.ts index 12483fa..5eb520d 100644 --- a/src/domain/vr-video/vr-video.controller.ts +++ b/src/domain/vr-video/vr-video.controller.ts @@ -32,7 +32,7 @@ export class VrVideoController { @AuthUser() user: User, @Body() requestDto: GenerateVrVideoRequestDto, ): Promise { - return await this.vrVideoService.generateVrVideo(user, requestDto); + return await this.vrVideoService.generateVrVideo(user, requestDto, true); } @ApiOperation({ summary: 'VR 비디오 정보 불러오기' }) @@ -43,17 +43,18 @@ export class VrVideoController { async getVrVideos(@AuthUser() user: User): Promise { return await this.vrVideoService.getVrVideos(user); } - - // 특정 VR 비디오 id를 통해서 특정한 VR비디오 정보를 불러오는 API를 작성할것. - @ApiOperation({ summary: 'VR 비디오 정보 불러오기' }) - @ApiBearerAuth() - @ApiResponse({ type: GetVrVideosResponseDto }) - @UseGuards(JwtAuthGuard, InitEnrollGuard) - @Get('/:id') - async getVrVideo( - @AuthUser() user: User, - @Param('id') videoId: string, - ): Promise { - return await this.vrVideoService.getVrVideo(user, videoId); - } } + +// deprecated +// // 특정 VR 비디오 id를 통해서 특정한 VR비디오 정보를 불러오는 API를 작성할것. +// @ApiOperation({ summary: 'VR 비디오 정보 불러오기' }) +// @ApiBearerAuth() +// @ApiResponse({ type: GetVrVideosResponseDto }) +// @UseGuards(JwtAuthGuard, InitEnrollGuard) +// @Get('/:id') +// async getVrVideo( +// @AuthUser() user: User, +// @Param('id') videoId: string, +// ): Promise { +// return await this.vrVideoService.getVrVideo(user, videoId); +// } diff --git a/src/domain/vr-video/vr-video.module.ts b/src/domain/vr-video/vr-video.module.ts index e06bd78..dfd8be6 100644 --- a/src/domain/vr-video/vr-video.module.ts +++ b/src/domain/vr-video/vr-video.module.ts @@ -23,5 +23,6 @@ import { VrResourceModule } from '../vr-resource/vr-resource.module'; ], controllers: [VrVideoController], providers: [VrVideoService, VrVideoRepository], + exports: [VrVideoService], }) export class VrVideoModule {}