diff --git a/backend/package.json b/backend/package.json index 589e2364e..6a1f8e332 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,8 @@ "migrate:dev:make": "node --loader ts-paths-esm-loader ../node_modules/knex/bin/cli.js migrate:make -x ts", "migrate:dev:down": "node --loader ts-paths-esm-loader ../node_modules/knex/bin/cli.js migrate:down", "migrate:dev:rollback": "node --loader ts-paths-esm-loader ../node_modules/knex/bin/cli.js migrate:rollback --all", + "seed:make": "node --loader ts-paths-esm-loader ../node_modules/knex/bin/cli.js seed:make -x ts", + "seed:run": "node --loader ts-paths-esm-loader ../node_modules/knex/bin/cli.js seed:run", "build": "tsc && tsc-alias", "start": "node ./src/index.js" }, @@ -53,6 +55,7 @@ "pino": "9.3.2", "pino-pretty": "10.3.1", "shared": "*", + "sharp": "0.33.5", "socket.io": "4.7.5", "swagger-jsdoc": "6.2.8", "tiktoken": "1.0.16" diff --git a/backend/src/bundles/avatar-videos/services/script-processor.service.ts b/backend/src/bundles/avatar-videos/services/script-processor.service.ts index 16344051e..26fe4df59 100644 --- a/backend/src/bundles/avatar-videos/services/script-processor.service.ts +++ b/backend/src/bundles/avatar-videos/services/script-processor.service.ts @@ -73,7 +73,22 @@ class ScriptProcessor { voice: string; scene: Scene; }): void { - if (text && this.currentAvatar) { + if (!text || !this.currentAvatar) { + return; + } + + const lastScene = this.result.at(-1); + + if ( + lastScene && + lastScene.avatar.voice === voice && + lastScene.avatar.name === this.currentAvatar.name && + lastScene.avatar.style === this.currentAvatar.style && + JSON.stringify(lastScene.background) === + JSON.stringify(scene.background) + ) { + lastScene.avatar.text += ' ' + text; + } else { this.result.push({ ...scene, id: uuidv4(), diff --git a/backend/src/bundles/public-video/public-videos.ts b/backend/src/bundles/public-video/public-videos.ts index 0018fc4b1..592c1cedb 100644 --- a/backend/src/bundles/public-video/public-videos.ts +++ b/backend/src/bundles/public-video/public-videos.ts @@ -1,11 +1,12 @@ import { VideoModel } from '~/bundles/videos/video.model.js'; import { VideoRepository } from '~/bundles/videos/video.repository.js'; import { logger } from '~/common/logger/logger.js'; +import { imageService } from '~/common/services/services.js'; import { PublicVideoController } from './public-video.controller.js'; import { PublicVideoService } from './public-video.service.js'; -const videoRepository = new VideoRepository(VideoModel); +const videoRepository = new VideoRepository(VideoModel, imageService); const videoService = new PublicVideoService(videoRepository); const publicVideoController = new PublicVideoController(logger, videoService); diff --git a/backend/src/bundles/templates/enums/enums.ts b/backend/src/bundles/templates/enums/enums.ts new file mode 100644 index 000000000..f85dd91b5 --- /dev/null +++ b/backend/src/bundles/templates/enums/enums.ts @@ -0,0 +1,2 @@ +export { templateErrorMessage } from './template-error-message.enum.js'; +export { templateApiPath } from './templates-api-path.enum.js'; diff --git a/backend/src/bundles/templates/enums/template-error-message.enum.ts b/backend/src/bundles/templates/enums/template-error-message.enum.ts new file mode 100644 index 000000000..d39d4c351 --- /dev/null +++ b/backend/src/bundles/templates/enums/template-error-message.enum.ts @@ -0,0 +1,7 @@ +const templateErrorMessage = { + YOU_CAN_NOT_DELETE_THIS_TEMPLATE: 'You can not delete this template', + YOU_CAN_NOT_UPDATE_THIS_TEMPLATE: 'You can not update this template', + TEMPLATE_DOES_NOT_EXIST: 'Template does not exist', +} as const; + +export { templateErrorMessage }; diff --git a/backend/src/bundles/templates/enums/templates-api-path.enum.ts b/backend/src/bundles/templates/enums/templates-api-path.enum.ts new file mode 100644 index 000000000..7b12bd25a --- /dev/null +++ b/backend/src/bundles/templates/enums/templates-api-path.enum.ts @@ -0,0 +1,8 @@ +const templateApiPath = { + ROOT: '/', + ID: '/:id', + PUBLIC: '/public', + USER: '/user', +} as const; + +export { templateApiPath }; diff --git a/backend/src/bundles/templates/templates.controller.ts b/backend/src/bundles/templates/templates.controller.ts new file mode 100644 index 000000000..c52672322 --- /dev/null +++ b/backend/src/bundles/templates/templates.controller.ts @@ -0,0 +1,434 @@ +import { + type ApiHandlerOptions, + type ApiHandlerResponse, + BaseController, +} from '~/common/controller/controller.js'; +import { ApiPath } from '~/common/enums/enums.js'; +import { HTTPCode, HTTPMethod } from '~/common/http/http.js'; +import { type Logger } from '~/common/logger/logger.js'; + +import { templateApiPath } from './enums/enums.js'; +import { type TemplateService } from './templates.service.js'; +import { + type CreateTemplateRequestDto, + type GetTemplateRequestDto, + type UpdateTemplateRequestDto, + type UserGetCurrentResponseDto, +} from './types/types.js'; +import { + createTemplateValidationSchema, + updateTemplateValidationSchema, +} from './validation-schemas/validation-schemas.js'; + +/** + * @swagger + * components: + * schemas: + * Template: + * type: object + * properties: + * id: + * type: string + * format: uuid + * userId: + * type: string + * format: uuid + * name: + * type: string + * previewUrl: + * type: string + * format: url + * composition: + * $ref: '#/components/schemas/Composition' + * Composition: + * type: object + * properties: + * scenes: + * type: array + * items: + * $ref: '#/components/schemas/Scene' + * scripts: + * type: array + * items: + * $ref: '#/components/schemas/Script' + * videoOrientation: + * type: string + * enum: + * - landscape + * - portrait + * SceneAvatar: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * style: + * type: string + * url: + * type: string + * format: url + * Scene: + * type: object + * properties: + * id: + * type: string + * duration: + * type: number + * avatar: + * $ref: '#/components/schemas/SceneAvatar' + * background: + * $ref: '#/components/schemas/SceneBackground' + * SceneBackground: + * type: object + * properties: + * color: + * type: string + * url: + * type: string + * format: url + * Script: + * type: object + * properties: + * id: + * type: string + * duration: + * type: number + * text: + * type: string + * voiceName: + * type: string + * url: + * type: string + * format: url + */ +class TemplateController extends BaseController { + private templateService: TemplateService; + + public constructor(logger: Logger, templateService: TemplateService) { + super(logger, ApiPath.TEMPLATES); + + this.templateService = templateService; + + this.addRoute({ + path: templateApiPath.USER, + method: HTTPMethod.GET, + handler: (options) => + this.findAllByUser( + options as ApiHandlerOptions<{ + user: UserGetCurrentResponseDto; + }>, + ), + }); + + this.addRoute({ + path: templateApiPath.PUBLIC, + method: HTTPMethod.GET, + handler: () => { + return this.findPublicTemplates(); + }, + }); + + this.addRoute({ + path: templateApiPath.ID, + method: HTTPMethod.GET, + handler: (options) => { + return this.findById( + options as ApiHandlerOptions<{ + params: GetTemplateRequestDto; + }>, + ); + }, + }); + + this.addRoute({ + path: templateApiPath.ROOT, + method: HTTPMethod.POST, + validation: { + body: createTemplateValidationSchema, + }, + handler: (options) => + this.create( + options as ApiHandlerOptions<{ + body: CreateTemplateRequestDto; + }>, + ), + }); + + this.addRoute({ + path: templateApiPath.ID, + method: HTTPMethod.PATCH, + validation: { + body: updateTemplateValidationSchema, + }, + handler: (options) => + this.update( + options as ApiHandlerOptions<{ + params: GetTemplateRequestDto; + body: UpdateTemplateRequestDto; + user: UserGetCurrentResponseDto; + }>, + ), + }); + + this.addRoute({ + path: templateApiPath.ID, + method: HTTPMethod.DELETE, + handler: (options) => + this.delete( + options as ApiHandlerOptions<{ + params: GetTemplateRequestDto; + user: UserGetCurrentResponseDto; + }>, + ), + }); + } + + /** + * @swagger + * /templates/user: + * get: + * description: Get all user templates + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * description: A list of templates objects + * items: + * $ref: '#/components/schemas/Template' + */ + + private async findAllByUser( + options: ApiHandlerOptions<{ + user: UserGetCurrentResponseDto; + }>, + ): Promise { + return { + status: HTTPCode.OK, + payload: await this.templateService.findByUserId(options.user.id), + }; + } + + /** + * @swagger + * /templates/public: + * get: + * description: Get all public templates + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * description: A list of public templates objects + * items: + * $ref: '#/components/schemas/Template' + */ + + private async findPublicTemplates(): Promise { + return { + status: HTTPCode.OK, + payload: await this.templateService.findPublicTemplates(), + }; + } + + /** + * @swagger + * /templates/{id}: + * patch: + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: The template id + * description: Update template by id + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Template' + */ + private async findById( + options: ApiHandlerOptions<{ + params: GetTemplateRequestDto; + }>, + ): Promise { + return { + status: HTTPCode.OK, + payload: await this.templateService.findById(options.params.id), + }; + } + + /** + * @swagger + * /templates/: + * post: + * description: Create new template + * security: + * - bearerAuth: [] + * requestBody: + * description: Template data + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, composition] + * properties: + * name: + * type: string + * composition: + * $ref: '#/components/schemas/Composition' + * responses: + * 201: + * description: Successful operation + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Template' + */ + + private async create( + options: ApiHandlerOptions<{ + body: CreateTemplateRequestDto; + }>, + ): Promise { + return { + status: HTTPCode.CREATED, + payload: await this.templateService.create({ + ...options.body, + userId: (options.user as UserGetCurrentResponseDto).id, + }), + }; + } + + /** + * @swagger + * /templates/{id}: + * patch: + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: The template id + * description: Update template by id + * security: + * - bearerAuth: [] + * requestBody: + * description: Template data + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, composition] + * properties: + * name: + * type: string + * composition: + * $ref: '#/components/schemas/Composition' + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Template' + * 404: + * description: Failed operation. The resource was not found. + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + + private async update( + options: ApiHandlerOptions<{ + params: GetTemplateRequestDto; + body: UpdateTemplateRequestDto; + user: UserGetCurrentResponseDto; + }>, + ): Promise { + return { + status: HTTPCode.OK, + payload: await this.templateService.updateTemplate( + options.params.id, + options.body, + options.user.id, + ), + }; + } + + /** + * @swagger + * /templates/{id}: + * delete: + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: The template id + * description: Delete template by id + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: boolean + * 404: + * description: Failed operation. The resource was not found. + * content: + * application/json: + * schema: + * type: object + * $ref: '#/components/schemas/Error' + */ + + private async delete( + options: ApiHandlerOptions<{ + params: GetTemplateRequestDto; + user: UserGetCurrentResponseDto; + }>, + ): Promise { + return { + status: HTTPCode.OK, + payload: await this.templateService.deleteTemplate( + options.params.id, + options.user.id, + ), + }; + } +} + +export { TemplateController }; diff --git a/backend/src/bundles/templates/templates.entity.ts b/backend/src/bundles/templates/templates.entity.ts new file mode 100644 index 000000000..ed7df7f28 --- /dev/null +++ b/backend/src/bundles/templates/templates.entity.ts @@ -0,0 +1,109 @@ +import { type Entity } from '~/common/types/types.js'; + +import { type Composition } from './types/types.js'; + +class TemplateEntity implements Entity { + private 'id': string | null; + + public 'name': string; + + public 'userId': string | null; + + public 'composition': Composition; + + public 'previewUrl': string; + + private constructor({ + id, + name, + userId, + composition, + previewUrl, + }: { + id: string | null; + name: string; + userId: string | null; + composition: Composition; + previewUrl: string; + }) { + this.id = id; + this.name = name; + this.userId = userId; + this.composition = composition; + this.previewUrl = previewUrl; + } + + public static initialize({ + id, + name, + userId, + composition, + previewUrl, + }: { + id: string; + name: string; + userId: string | null; + composition: Composition; + previewUrl: string; + }): TemplateEntity { + return new TemplateEntity({ + id, + name, + userId: userId, + composition, + previewUrl, + }); + } + + public static initializeNew({ + name, + userId, + composition, + previewUrl, + }: { + name: string; + userId?: string; + composition: Composition; + previewUrl: string; + }): TemplateEntity { + return new TemplateEntity({ + id: null, + name, + userId: userId ?? null, + composition, + previewUrl, + }); + } + + public toObject(): { + id: string; + name: string; + userId: string | null; + composition: Composition; + previewUrl: string; + } { + return { + id: this.id as string, + name: this.name, + userId: this.userId, + composition: this.composition, + previewUrl: this.previewUrl, + }; + } + + public toNewObject(): { + name: string; + userId: string | null; + composition: Composition; + previewUrl: string; + } { + return { + name: this.name, + userId: this.userId, + composition: this.composition, + previewUrl: this.previewUrl, + }; + } +} + +export { TemplateEntity }; diff --git a/backend/src/bundles/templates/templates.model.ts b/backend/src/bundles/templates/templates.model.ts new file mode 100644 index 000000000..c2aa53caf --- /dev/null +++ b/backend/src/bundles/templates/templates.model.ts @@ -0,0 +1,22 @@ +import { + AbstractModel, + DatabaseTableName, +} from '~/common/database/database.js'; + +import { type Composition } from './types/types.js'; + +class TemplateModel extends AbstractModel { + public 'name': string; + + public 'userId': string | null; + + public 'composition': Composition; + + public 'previewUrl': string; + + public static override get tableName(): string { + return DatabaseTableName.TEMPLATES; + } +} + +export { TemplateModel }; diff --git a/backend/src/bundles/templates/templates.repository.ts b/backend/src/bundles/templates/templates.repository.ts new file mode 100644 index 000000000..bf7034d9d --- /dev/null +++ b/backend/src/bundles/templates/templates.repository.ts @@ -0,0 +1,87 @@ +import { type UpdateTemplateRequestDto } from 'shared'; + +import { type Repository } from '~/common/types/types.js'; + +import { TemplateEntity } from './templates.entity.js'; +import { type TemplateModel } from './templates.model.js'; + +class TemplateRepository implements Repository { + private templateModel: typeof TemplateModel; + + public constructor(templateModel: typeof TemplateModel) { + this.templateModel = templateModel; + } + + public async findById(id: string): Promise { + const template = await this.templateModel + .query() + .findById(id) + .execute(); + + return template ? TemplateEntity.initialize(template) : null; + } + + public async findPublicTemplates(): Promise { + const templates = await this.templateModel + .query() + .where('userId', null) + .execute(); + + return templates.map((it) => TemplateEntity.initialize(it)); + } + + public async findByUserId(userId: string): Promise { + const videos = await this.templateModel + .query() + .where('userId', userId) + .execute(); + + return videos.map((it) => TemplateEntity.initialize(it)); + } + + public async findAll(): Promise { + const templates = await this.templateModel.query().execute(); + + return templates.map((it) => TemplateEntity.initialize(it)); + } + + public async create(entity: TemplateEntity): Promise { + const { composition, name, previewUrl, userId } = entity.toNewObject(); + + const item = await this.templateModel + .query() + .insert({ + userId, + composition, + name, + previewUrl, + }) + .returning('*') + .execute(); + + return TemplateEntity.initialize(item); + } + + public async update( + id: string, + payload: UpdateTemplateRequestDto, + ): Promise { + const updatedItem = await this.templateModel + .query() + .patchAndFetchById(id, payload) + .execute(); + + return updatedItem ? TemplateEntity.initialize(updatedItem) : null; + } + + public async delete(id: string): ReturnType { + const numberOfDeletedRows = await this.templateModel + .query() + .deleteById(id) + .execute(); + + return Boolean(numberOfDeletedRows); + } +} + +export { TemplateRepository }; diff --git a/backend/src/bundles/templates/templates.service.ts b/backend/src/bundles/templates/templates.service.ts new file mode 100644 index 000000000..bf97c4e1b --- /dev/null +++ b/backend/src/bundles/templates/templates.service.ts @@ -0,0 +1,155 @@ +import { HTTPCode, HttpError } from '~/common/http/http.js'; +import { type ImageService } from '~/common/services/image/image.service.js'; +import { type Service } from '~/common/types/types.js'; + +import { + type Scene, + type UpdateVideoRequestDto, +} from '../videos/types/types.js'; +import { templateErrorMessage } from './enums/enums.js'; +import { TemplateEntity } from './templates.entity.js'; +import { type TemplateRepository } from './templates.repository.js'; +import { + type CreateTemplateRequestDto, + type CreateTemplateResponseDto, + type GetTemplatesResponseDto, + type Template, +} from './types/types.js'; + +class TemplateService implements Service { + private templateRepository: TemplateRepository; + private imageService: ImageService; + + public constructor( + templateRepository: TemplateRepository, + imageService: ImageService, + ) { + this.templateRepository = templateRepository; + this.imageService = imageService; + } + + public async findById(id: string): Promise { + return await this.templateRepository.findById(id); + } + + public async findPublicTemplates(): Promise { + const items = await this.templateRepository.findPublicTemplates(); + return { + items: items.map((it) => it.toObject()), + }; + } + + public async findByUserId( + userId: string, + ): Promise { + const items = await this.templateRepository.findByUserId(userId); + + return { + items: items.map((it) => it.toObject()), + }; + } + + public async findAll(): Promise { + const items = await this.templateRepository.findAll(); + + return { + items: items.map((it) => it.toObject()), + }; + } + + public async create( + payload: CreateTemplateRequestDto & { userId: string }, + ): Promise { + const { composition, name, userId } = payload; + + // TODO: CREATE PREVIEW + const compositionPreviewUrl = await this.imageService.generatePreview( + composition.scenes[0] as Scene, + ); + + const user = await this.templateRepository.create( + TemplateEntity.initializeNew({ + composition, + name, + userId, + previewUrl: compositionPreviewUrl, + }), + ); + + return user.toObject(); + } + + public async updateTemplate( + id: string, + payload: UpdateVideoRequestDto & { previewUrl?: string }, + userId: string, + ): Promise