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/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<ApiHandlerResponse> { + 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<ApiHandlerResponse> { + 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<ApiHandlerResponse> { + 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<ApiHandlerResponse> { + 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<ApiHandlerResponse> { + 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<ApiHandlerResponse> { + 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<TemplateEntity | null> { + const template = await this.templateModel + .query() + .findById(id) + .execute(); + + return template ? TemplateEntity.initialize(template) : null; + } + + public async findPublicTemplates(): Promise<TemplateEntity[]> { + const templates = await this.templateModel + .query() + .where('userId', null) + .execute(); + + return templates.map((it) => TemplateEntity.initialize(it)); + } + + public async findByUserId(userId: string): Promise<TemplateEntity[]> { + const videos = await this.templateModel + .query() + .where('userId', userId) + .execute(); + + return videos.map((it) => TemplateEntity.initialize(it)); + } + + public async findAll(): Promise<TemplateEntity[]> { + const templates = await this.templateModel.query().execute(); + + return templates.map((it) => TemplateEntity.initialize(it)); + } + + public async create(entity: TemplateEntity): Promise<TemplateEntity> { + 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<TemplateEntity | null> { + const updatedItem = await this.templateModel + .query() + .patchAndFetchById(id, payload) + .execute(); + + return updatedItem ? TemplateEntity.initialize(updatedItem) : null; + } + + public async delete(id: string): ReturnType<Repository['delete']> { + 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<TemplateEntity | null> { + return await this.templateRepository.findById(id); + } + + public async findPublicTemplates(): Promise<GetTemplatesResponseDto> { + const items = await this.templateRepository.findPublicTemplates(); + return { + items: items.map((it) => it.toObject()), + }; + } + + public async findByUserId( + userId: string, + ): Promise<GetTemplatesResponseDto> { + const items = await this.templateRepository.findByUserId(userId); + + return { + items: items.map((it) => it.toObject()), + }; + } + + public async findAll(): Promise<GetTemplatesResponseDto> { + const items = await this.templateRepository.findAll(); + + return { + items: items.map((it) => it.toObject()), + }; + } + + public async create( + payload: CreateTemplateRequestDto & { userId: string }, + ): Promise<CreateTemplateResponseDto> { + 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<Template> { + const template = await this.findById(id); + + if (template?.userId !== userId) { + throw new HttpError({ + message: templateErrorMessage.YOU_CAN_NOT_UPDATE_THIS_TEMPLATE, + status: HTTPCode.BAD_REQUEST, + }); + } + + if (payload.composition) { + payload.previewUrl = await this.imageService.generatePreview( + payload.composition.scenes[0] as Scene, + ); + } + + return await this.update(id, payload); + } + + public async update( + id: string, + payload: UpdateVideoRequestDto, + ): Promise<Template> { + const updatedTemplate = await this.templateRepository.update( + id, + payload, + ); + + if (!updatedTemplate) { + throw new HttpError({ + message: templateErrorMessage.TEMPLATE_DOES_NOT_EXIST, + status: HTTPCode.NOT_FOUND, + }); + } + + return updatedTemplate.toObject(); + } + + public async deleteTemplate( + templateId: string, + userId: string, + ): Promise<boolean> { + const template = await this.findById(templateId); + + if (template?.userId !== userId) { + throw new HttpError({ + message: templateErrorMessage.YOU_CAN_NOT_DELETE_THIS_TEMPLATE, + status: HTTPCode.BAD_REQUEST, + }); + } + + return await this.delete(templateId); + } + + public async delete(templateId: string): Promise<boolean> { + const isTemplateDeleted = + await this.templateRepository.delete(templateId); + + if (!isTemplateDeleted) { + throw new HttpError({ + message: templateErrorMessage.TEMPLATE_DOES_NOT_EXIST, + status: HTTPCode.NOT_FOUND, + }); + } + + return isTemplateDeleted; + } +} + +export { TemplateService }; diff --git a/backend/src/bundles/templates/templates.ts b/backend/src/bundles/templates/templates.ts new file mode 100644 index 000000000..244cb083b --- /dev/null +++ b/backend/src/bundles/templates/templates.ts @@ -0,0 +1,13 @@ +import { logger } from '~/common/logger/logger.js'; +import { imageService } from '~/common/services/services.js'; + +import { TemplateController } from './templates.controller.js'; +import { TemplateModel } from './templates.model.js'; +import { TemplateRepository } from './templates.repository.js'; +import { TemplateService } from './templates.service.js'; + +const templateRepository = new TemplateRepository(TemplateModel); +const templateService = new TemplateService(templateRepository, imageService); +const templateController = new TemplateController(logger, templateService); + +export { templateController }; diff --git a/backend/src/bundles/templates/types/types.ts b/backend/src/bundles/templates/types/types.ts new file mode 100644 index 000000000..b648eb4ea --- /dev/null +++ b/backend/src/bundles/templates/types/types.ts @@ -0,0 +1,10 @@ +export { + type Composition, + type CreateTemplateRequestDto, + type CreateTemplateResponseDto, + type GetTemplateRequestDto, + type GetTemplatesResponseDto, + type Template, + type UpdateTemplateRequestDto, + type UserGetCurrentResponseDto, +} from 'shared'; diff --git a/backend/src/bundles/templates/validation-schemas/validation-schemas.ts b/backend/src/bundles/templates/validation-schemas/validation-schemas.ts new file mode 100644 index 000000000..526d7df6d --- /dev/null +++ b/backend/src/bundles/templates/validation-schemas/validation-schemas.ts @@ -0,0 +1,4 @@ +export { + createTemplateValidationSchema, + updateTemplateValidationSchema, +} from 'shared'; diff --git a/backend/src/bundles/videos/types/types.ts b/backend/src/bundles/videos/types/types.ts index 0e99b9510..86eea3daa 100644 --- a/backend/src/bundles/videos/types/types.ts +++ b/backend/src/bundles/videos/types/types.ts @@ -1,5 +1,6 @@ export { type CreateVideoRequestDto, + type Scene, type UpdateVideoRequestDto, type UserGetCurrentResponseDto, type VideoGetAllItemResponseDto, diff --git a/backend/src/bundles/videos/video.repository.ts b/backend/src/bundles/videos/video.repository.ts index 656af139f..ed0b8bc91 100644 --- a/backend/src/bundles/videos/video.repository.ts +++ b/backend/src/bundles/videos/video.repository.ts @@ -1,13 +1,22 @@ -import { type UpdateVideoRequestDto } from '~/bundles/videos/types/types.js'; +import { + type Scene, + type UpdateVideoRequestDto, +} from '~/bundles/videos/types/types.js'; import { VideoEntity } from '~/bundles/videos/video.entity.js'; import { type VideoModel } from '~/bundles/videos/video.model.js'; +import { type ImageService } from '~/common/services/image/image.service.js'; import { type Repository } from '~/common/types/types.js'; class VideoRepository implements Repository { private videoModel: typeof VideoModel; + private imageService: ImageService; - public constructor(videoModel: typeof VideoModel) { + public constructor( + videoModel: typeof VideoModel, + imageService: ImageService, + ) { this.videoModel = videoModel; + this.imageService = imageService; } public async findById(id: string): Promise<VideoEntity | null> { @@ -58,7 +67,9 @@ class VideoRepository implements Repository { if (payload.composition) { data.composition = payload.composition; - data.previewUrl = payload.composition.scenes[0]?.avatar?.url ?? ''; + data.previewUrl = await this.imageService.generatePreview( + payload.composition.scenes[0] as Scene, + ); } if (payload.name) { diff --git a/backend/src/bundles/videos/video.service.ts b/backend/src/bundles/videos/video.service.ts index f40ef682d..42c7fe4ae 100644 --- a/backend/src/bundles/videos/video.service.ts +++ b/backend/src/bundles/videos/video.service.ts @@ -1,12 +1,14 @@ import { VideoEntity } from '~/bundles/videos/video.entity.js'; import { type VideoRepository } from '~/bundles/videos/video.repository.js'; import { HTTPCode, HttpError } from '~/common/http/http.js'; +import { type ImageService } from '~/common/services/image/image.service.js'; import { type RemotionService } from '~/common/services/remotion/remotion.service.js'; import { type Service } from '~/common/types/types.js'; import { VideoValidationMessage } from './enums/enums.js'; import { type CreateVideoRequestDto, + type Scene, type UpdateVideoRequestDto, type VideoGetAllItemResponseDto, type VideoGetAllResponseDto, @@ -15,13 +17,16 @@ import { class VideoService implements Service { private videoRepository: VideoRepository; private remotionService: RemotionService; + private imageService: ImageService; public constructor( videoRepository: VideoRepository, remotionService: RemotionService, + imageService: ImageService, ) { this.videoRepository = videoRepository; this.remotionService = remotionService; + this.imageService = imageService; } public async findById(id: string): Promise<VideoGetAllItemResponseDto> { @@ -56,11 +61,15 @@ class VideoService implements Service { public async create( payload: CreateVideoRequestDto & { userId: string }, ): Promise<VideoGetAllItemResponseDto> { + const previewUrl = await this.imageService.generatePreview( + payload.composition.scenes[0] as Scene, + ); + const video = await this.videoRepository.create( VideoEntity.initializeNew({ name: payload.name, composition: payload.composition, - previewUrl: payload.composition?.scenes[0]?.avatar?.url ?? '', + previewUrl, userId: payload.userId, }), ); diff --git a/backend/src/bundles/videos/videos.ts b/backend/src/bundles/videos/videos.ts index 7be20cecf..1aa3c5f1c 100644 --- a/backend/src/bundles/videos/videos.ts +++ b/backend/src/bundles/videos/videos.ts @@ -1,13 +1,17 @@ import { logger } from '~/common/logger/logger.js'; -import { remotionService } from '~/common/services/services.js'; +import { imageService, remotionService } from '~/common/services/services.js'; import { VideoController } from './video.controller.js'; import { VideoModel } from './video.model.js'; import { VideoRepository } from './video.repository.js'; import { VideoService } from './video.service.js'; -const videoRepository = new VideoRepository(VideoModel); -const videoService = new VideoService(videoRepository, remotionService); +const videoRepository = new VideoRepository(VideoModel, imageService); +const videoService = new VideoService( + videoRepository, + remotionService, + imageService, +); const videoController = new VideoController(logger, videoService); export { videoController, videoService }; diff --git a/backend/src/common/database/base-database.package.ts b/backend/src/common/database/base-database.package.ts index ab87d8d9d..174cd3283 100644 --- a/backend/src/common/database/base-database.package.ts +++ b/backend/src/common/database/base-database.package.ts @@ -51,6 +51,9 @@ class BaseDatabase implements Database { directory: 'src/migrations', tableName: DatabaseTableName.MIGRATIONS, }, + seeds: { + directory: 'src/seeds', + }, debug: false, ...knexSnakeCaseMappers({ underscoreBetweenUppercaseLetters: true, diff --git a/backend/src/common/database/enums/database-table-name.enum.ts b/backend/src/common/database/enums/database-table-name.enum.ts index f4d42be12..aee703595 100644 --- a/backend/src/common/database/enums/database-table-name.enum.ts +++ b/backend/src/common/database/enums/database-table-name.enum.ts @@ -9,6 +9,7 @@ const DatabaseTableName = { AVATARS_STYLES: 'avatars_styles', AVATARS_STYLES_GESTURES: 'avatars_styles_gestures', FILES: 'files', + TEMPLATES: 'templates', } as const; export { DatabaseTableName }; diff --git a/backend/src/common/server-application/server-application.ts b/backend/src/common/server-application/server-application.ts index f940b4b54..feb24194d 100644 --- a/backend/src/common/server-application/server-application.ts +++ b/backend/src/common/server-application/server-application.ts @@ -4,6 +4,7 @@ import { avatarController } from '~/bundles/avatars/avatars.js'; import { chatController } from '~/bundles/chat/chat.js'; import { notificationController } from '~/bundles/notifications/notifications.js'; import { speechController } from '~/bundles/speech/speech.js'; +import { templateController } from '~/bundles/templates/templates.js'; import { userController } from '~/bundles/users/users.js'; import { videoController } from '~/bundles/videos/videos.js'; import { config } from '~/common/config/config.js'; @@ -24,6 +25,7 @@ const apiV1 = new BaseServerAppApi( ...chatController.routes, ...speechController.routes, ...avatarVideoController.routes, + ...templateController.routes, ); const serverApp = new BaseServerApp({ diff --git a/backend/src/common/services/image/constants/constants.ts b/backend/src/common/services/image/constants/constants.ts new file mode 100644 index 000000000..8ee50667c --- /dev/null +++ b/backend/src/common/services/image/constants/constants.ts @@ -0,0 +1,4 @@ +const PREVIEW_WIDTH = 1920; +const PREVIEW_HEIGHT = 1080; + +export { PREVIEW_HEIGHT, PREVIEW_WIDTH }; diff --git a/backend/src/common/services/image/image-base.ts b/backend/src/common/services/image/image-base.ts new file mode 100644 index 000000000..73694ff10 --- /dev/null +++ b/backend/src/common/services/image/image-base.ts @@ -0,0 +1,25 @@ +import { type Http, ContentType, HTTPMethod } from 'shared'; + +import { BaseHttpApi } from '~/common/http/base-http-api.js'; + +type Constructor = { + http: Http; +}; + +class ImageApi extends BaseHttpApi { + public constructor({ http }: Constructor) { + super({ path: '', baseUrl: '', http }); + } + + public async getImageBuffer(url: string): Promise<Buffer> { + const response = await this.load(url, { + method: HTTPMethod.GET, + contentType: ContentType.IMAGE, + }); + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } +} + +export { ImageApi }; diff --git a/backend/src/common/services/image/image.service.ts b/backend/src/common/services/image/image.service.ts new file mode 100644 index 000000000..6cb0b6a9b --- /dev/null +++ b/backend/src/common/services/image/image.service.ts @@ -0,0 +1,109 @@ +import sharp from 'sharp'; + +import { type FileService } from '~/common/services/file/file.service.js'; + +import { PREVIEW_HEIGHT, PREVIEW_WIDTH } from './constants/constants.js'; +import { type ImageApi } from './image-base.js'; +import { type Scene } from './types/types.js'; + +type Constructor = { + fileService: FileService; + imageApi: ImageApi; +}; + +class ImageService { + private fileService: FileService; + private imageApi: ImageApi; + + public constructor({ fileService, imageApi }: Constructor) { + this.fileService = fileService; + this.imageApi = imageApi; + } + + public async generatePreview(scene: Scene): Promise<string> { + const avatarImage = scene.avatar?.url ?? ''; + const background = scene.background; + + if (background?.url) { + const backgroundImageBuffer = await this.imageApi.getImageBuffer( + background.url, + ); + + return await this.combineAvatarWithBackground( + avatarImage, + backgroundImageBuffer, + ); + } + + if (background?.color) { + const backgroundColorImageBuffer = + await this.createImageWithBackgroundColor(background.color); + + return await this.combineAvatarWithBackground( + avatarImage, + backgroundColorImageBuffer, + ); + } + + return avatarImage; + } + + private async combineAvatarWithBackground( + avatarImage: string, + background: Buffer, + ): Promise<string> { + const avatarImageBuffer = + await this.imageApi.getImageBuffer(avatarImage); + + const previewBuffer = await this.composeImages( + avatarImageBuffer, + background, + ); + + const fileName = `preview_${Date.now()}.jpg`; + + await this.fileService.uploadFile(previewBuffer, fileName); + + return this.fileService.getCloudFrontFileUrl(fileName); + } + + private async composeImages( + avatar: Buffer, + background: Buffer, + ): Promise<Buffer> { + const resizedBackground = await sharp(background) + .resize(PREVIEW_WIDTH, PREVIEW_HEIGHT, { + fit: 'cover', + position: 'center', + }) + .toBuffer(); + + const resizedAvatar = await sharp(avatar) + .resize(PREVIEW_WIDTH, PREVIEW_HEIGHT, { + fit: 'inside', + position: 'bottom', + }) + .toBuffer(); + + return await sharp(resizedBackground) + .composite([{ input: resizedAvatar, blend: 'over' }]) + .toBuffer(); + } + + private async createImageWithBackgroundColor( + backgroundColor: string, + ): Promise<Buffer> { + return await sharp({ + create: { + width: PREVIEW_WIDTH, + height: PREVIEW_HEIGHT, + channels: 3, + background: backgroundColor, + }, + }) + .toFormat('png') + .toBuffer(); + } +} + +export { ImageService }; diff --git a/backend/src/common/services/image/image.ts b/backend/src/common/services/image/image.ts new file mode 100644 index 000000000..14b4c7f9d --- /dev/null +++ b/backend/src/common/services/image/image.ts @@ -0,0 +1,7 @@ +import { baseHttp } from '~/common/http/http.js'; + +import { ImageApi } from './image-base.js'; + +const imageApi = new ImageApi({ http: baseHttp }); + +export { imageApi }; diff --git a/backend/src/common/services/image/types/types.ts b/backend/src/common/services/image/types/types.ts new file mode 100644 index 000000000..23de6349e --- /dev/null +++ b/backend/src/common/services/image/types/types.ts @@ -0,0 +1 @@ +export { type Scene } from 'shared'; diff --git a/backend/src/common/services/services.ts b/backend/src/common/services/services.ts index 8d4bc32d8..ffe6ceee6 100644 --- a/backend/src/common/services/services.ts +++ b/backend/src/common/services/services.ts @@ -5,6 +5,8 @@ import { AzureAIService } from './azure-ai/azure-ai.service.js'; import { textToSpeechApi } from './azure-ai/text-to-speech/text-to-speech.js'; import { CryptService } from './crypt/crypt.service.js'; import { FileService } from './file/file.service.js'; +import { imageApi } from './image/image.js'; +import { ImageService } from './image/image.service.js'; import { OpenAIService } from './open-ai/open-ai.service.js'; import { RemotionService } from './remotion/remotion.service.js'; import { TokenService } from './token/token.services.js'; @@ -23,11 +25,13 @@ const azureAIService = new AzureAIService({ const secretKey = config.ENV.TOKEN.SECRET_KEY; const expirationTime = config.ENV.TOKEN.EXPIRATION_TIME; const tokenService = new TokenService(secretKey, expirationTime); +const imageService = new ImageService({ fileService, imageApi }); export { azureAIService, cryptService, fileService, + imageService, openAIService, remotionService, tokenService, diff --git a/backend/src/migrations/20240925063942_add_templates_table.ts b/backend/src/migrations/20240925063942_add_templates_table.ts new file mode 100644 index 000000000..0ff03d23a --- /dev/null +++ b/backend/src/migrations/20240925063942_add_templates_table.ts @@ -0,0 +1,46 @@ +import { type Knex } from 'knex'; + +const TABLE_NAME = 'templates'; + +const ColumnName = { + ID: 'id', + NAME: 'name', + USER_ID: 'user_id', + COMPOSITION: 'composition', + PREVIEW_URL: 'preview_url', + CREATED_AT: 'created_at', + UPDATED_AT: 'updated_at', +}; + +async function up(knex: Knex): Promise<void> { + await knex.schema.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'); + await knex.schema.createTable(TABLE_NAME, (table) => { + table + .uuid('id') + .notNullable() + .primary() + .defaultTo(knex.raw('uuid_generate_v4()')); + table.string(ColumnName.NAME).notNullable(); + table.uuid(ColumnName.USER_ID).nullable().defaultTo(null); + table.jsonb(ColumnName.COMPOSITION).notNullable(); + table.string(ColumnName.PREVIEW_URL).notNullable(); + table + .dateTime(ColumnName.CREATED_AT) + .notNullable() + .defaultTo(knex.fn.now()); + table + .dateTime(ColumnName.UPDATED_AT) + .notNullable() + .defaultTo(knex.fn.now()); + table + .foreign(ColumnName.USER_ID) + .references('users.id') + .onDelete('CASCADE'); + }); +} + +async function down(knex: Knex): Promise<void> { + return knex.schema.dropTableIfExists(TABLE_NAME); +} + +export { down, up }; diff --git a/backend/src/seed-data/templates-seed.ts b/backend/src/seed-data/templates-seed.ts new file mode 100644 index 000000000..faa459d2f --- /dev/null +++ b/backend/src/seed-data/templates-seed.ts @@ -0,0 +1,188 @@ +const videoOrientation = { + LANDSCAPE: 'landscape', + PORTRAIT: 'portrait', +}; + +const templatesSeed = [ + { + name: 'Landscape with Max', + user_id: null, + preview_url: + 'https://d2tm5q3cg1nlwf.cloudfront.net/preview_1727352836443.jpg', + composition: { + scenes: [ + { + id: 'cc343716-c871-4f84-a136-73e66d2b0c18', + avatar: { + id: 'f65c41d0-dd51-4623-a4a6-8ec7e10a2ed5', + name: 'Max', + style: 'business', + url: 'https://speech.microsoft.com/assets/avatar/max/max-business-thumbnail.png', + }, + background: { + url: 'https://d2tm5q3cg1nlwf.cloudfront.net/wall.jpg', + }, + duration: 15, + }, + ], + scripts: [ + { + id: 'ca14d294-5cf0-47e0-9587-66b8ca80dfb0', + text: 'Hello, this is our template that uses the avatar Max. Enjoy your video editing!', + voiceName: 'en-US-BrianMultilingualNeural', + duration: 10, + }, + ], + videoOrientation: videoOrientation.LANDSCAPE, + }, + }, + { + name: 'Portrait with Lori', + user_id: null, + preview_url: + 'https://d2tm5q3cg1nlwf.cloudfront.net/preview_1727352905018.jpg', + composition: { + scenes: [ + { + id: '3e3053c3-2d22-4558-9799-3356a7936c97', + avatar: { + id: '94b9d9ca-f573-47b0-92e9-133aaa5026a6', + name: 'lori', + style: 'casual', + url: 'https://speech.microsoft.com/assets/avatar/lori/lori-casual-thumbnail.png', + }, + background: { + color: '#FFE9D0', + }, + duration: 15, + }, + ], + scripts: [ + { + id: '48607983-b9f1-4116-955c-097cd7f89354', + text: 'Hello, my name is Lori. I will help you create the best video you want!', + voiceName: 'en-CA-ClaraNeural', + duration: 10, + }, + ], + videoOrientation: videoOrientation.PORTRAIT, + }, + }, + { + name: 'Landscape with Harry and Max', + user_id: null, + preview_url: + 'https://d2tm5q3cg1nlwf.cloudfront.net/preview_1727352956648.jpg', + composition: { + scenes: [ + { + id: '9ac8341a-987a-43ab-8784-2909e0a30653', + avatar: { + id: 'b58f9707-e4a4-49fc-8270-5984e70deb70', + name: 'harry', + style: 'casual', + url: 'https://speech.microsoft.com/assets/avatar/harry/harry-casual-thumbnail.png', + }, + background: { + url: 'https://d2tm5q3cg1nlwf.cloudfront.net/city.webp', + }, + duration: 27, + }, + { + id: '6da6a0e4-61d6-4501-aab2-efab2cb19adf', + avatar: { + id: 'f65c41d0-dd51-4623-a4a6-8ec7e10a2ed5', + name: 'max', + style: 'formal', + url: 'https://speech.microsoft.com/assets/avatar/max/max-formal-thumbnail.png', + }, + background: { + url: 'https://d2tm5q3cg1nlwf.cloudfront.net/city.webp', + }, + duration: 22, + }, + ], + scripts: [ + { + id: '44f0a107-ed62-4b7b-ab20-d6fc6d991af9', + text: 'Nestled between rolling hills, the city gleams under the golden sunlight. Its cobblestone streets wind gracefully through vibrant squares and open plazas, where the sound of laughter and conversation fills the air. Elegant buildings, with their terracotta roofs and sun-warmed stone facades, line the streets. Flower boxes overflowing with bright blooms adorn the windows, and vines creep gently up the walls, adding touches of green to the warm tones of the city.', + voiceName: 'en-AU-DuncanNeural', + duration: 27.3875, + }, + { + id: 'c3357306-b77d-4e6b-9348-e59a76ddb6f7', + text: 'Surrounding the city is a breathtaking landscape of lush, green meadows and gentle hills. The countryside is dotted with fields of wildflowers, their colors painting the horizon in hues of violet, yellow, and red. The air is fresh, carrying the sweet fragrance of blooming lavender and the earthy scent of pine from the distant woods.', + voiceName: 'en-US-BrianMultilingualNeural', + duration: 21.375, + }, + ], + videoOrientation: videoOrientation.LANDSCAPE, + }, + }, + { + name: 'Portrait with Lisa', + user_id: null, + preview_url: + 'https://d2tm5q3cg1nlwf.cloudfront.net/preview_1727353044738.jpg', + composition: { + scenes: [ + { + id: '097791b0-081e-40b5-95c5-e89d090111e3', + avatar: { + id: '4038faa8-172e-4616-a16d-79dc004103ff', + name: 'lisa', + style: 'casual-sitting', + url: 'https://speech.microsoft.com/assets/avatar/lisa/lisa-casual-sitting-thumbnail.png', + }, + background: { + url: 'https://d2tm5q3cg1nlwf.cloudfront.net/library.jfif', + }, + duration: 12, + }, + ], + scripts: [ + { + id: 'ad8bf2b5-f969-4ea6-b57e-901459c4e10e', + text: 'The library is a quiet haven, filled with towering shelves that hold books of every kind. Sunlight pours through large windows, casting a soft glow over the cozy reading corners. The scent of aged paper and polished wood fills the air, inviting visitors to explore the endless knowledge and stories within. It is a peaceful place where time slows, and curiosity thrives.', + voiceName: 'en-AU-AnnetteNeural', + duration: 23.2, + }, + ], + videoOrientation: videoOrientation.PORTRAIT, + }, + }, + { + name: 'Landscape with Jeff', + user_id: null, + preview_url: + 'https://d2tm5q3cg1nlwf.cloudfront.net/preview_1727353097406.jpg', + composition: { + scenes: [ + { + id: '64b668ca-2d81-49c1-bba4-29896227b366', + avatar: { + id: 'e531c2c9-e06b-40f8-9fda-7b4168f79640', + name: 'jeff', + style: 'formal', + url: 'https://speech.microsoft.com/assets/avatar/jeff/jeff-formal-thumbnail-bg.png', + }, + background: { + color: '#91DDCF', + }, + duration: 15, + }, + ], + scripts: [ + { + id: '2203984b-c461-4e3f-9569-d55f7a1d97ce', + text: 'Hello, my name is Jeff. With my help, you can create enjoyable videos.', + voiceName: 'en-GB-RyanNeural', + duration: 6.35, + }, + ], + videoOrientation: videoOrientation.LANDSCAPE, + }, + }, +]; + +export { templatesSeed }; diff --git a/backend/src/seeds/fill-templates-table.ts b/backend/src/seeds/fill-templates-table.ts new file mode 100644 index 000000000..56642656a --- /dev/null +++ b/backend/src/seeds/fill-templates-table.ts @@ -0,0 +1,21 @@ +import { type Knex } from 'knex'; + +import { templatesSeed } from '../seed-data/templates-seed.js'; + +const TableName = { + TEMPLATES: 'templates', +} as const; + +async function seed(knex: Knex): Promise<void> { + await knex.transaction(async (trx) => { + await trx(TableName.TEMPLATES).del(); + + const templatesMappedSeed = templatesSeed.map((template) => ({ + ...template, + })); + + await trx(TableName.TEMPLATES).insert(templatesMappedSeed); + }); +} + +export { seed }; diff --git a/frontend/src/bundles/common/components/button/button.tsx b/frontend/src/bundles/common/components/button/button.tsx index 5cc794ede..ec9afd898 100644 --- a/frontend/src/bundles/common/components/button/button.tsx +++ b/frontend/src/bundles/common/components/button/button.tsx @@ -11,6 +11,7 @@ type Properties = { isDisabled?: boolean; className?: string | undefined; onClick?: () => void; + leftIcon?: React.ReactElement; } & ChakraProperties; const Button: React.FC<Properties> = ({ @@ -20,6 +21,7 @@ const Button: React.FC<Properties> = ({ size = 'md', isDisabled = false, className, + leftIcon, onClick, ...ChakraProperties }) => ( @@ -31,6 +33,7 @@ const Button: React.FC<Properties> = ({ isDisabled={isDisabled} className={className} onClick={onClick} + {...(leftIcon ? { leftIcon } : {})} {...ChakraProperties} > {label} diff --git a/frontend/src/bundles/common/components/components.ts b/frontend/src/bundles/common/components/components.ts index f535d52f4..9d938499a 100644 --- a/frontend/src/bundles/common/components/components.ts +++ b/frontend/src/bundles/common/components/components.ts @@ -48,6 +48,7 @@ export { IconButton, Image, InputGroup, + InputLeftElement, InputRightElement, Button as LibraryButton, Input as LibraryInput, diff --git a/frontend/src/bundles/common/components/input/input.tsx b/frontend/src/bundles/common/components/input/input.tsx index a27a862be..451e1aea5 100644 --- a/frontend/src/bundles/common/components/input/input.tsx +++ b/frontend/src/bundles/common/components/input/input.tsx @@ -14,7 +14,7 @@ import { import { useFormField } from '~/bundles/common/hooks/hooks.js'; type Properties<T extends FormValues> = { - label: string; + label?: string; name: FieldInputProps<T>['name']; type?: 'text' | 'email' | 'number' | 'password'; isRequired?: boolean; diff --git a/frontend/src/bundles/common/components/select/select.tsx b/frontend/src/bundles/common/components/select/select.tsx index 20cce289c..a0114ffdf 100644 --- a/frontend/src/bundles/common/components/select/select.tsx +++ b/frontend/src/bundles/common/components/select/select.tsx @@ -13,7 +13,7 @@ import { import { useFormField } from '~/bundles/common/hooks/hooks.js'; type Properties<T extends FormValues> = { - label: string; + label?: string; name: FieldInputProps<T>['name']; children: React.ReactNode; isRequired?: boolean; @@ -31,7 +31,7 @@ const Select: React.FC<Properties<FormValues>> = ({ return ( <FormControl isInvalid={!isValid} isRequired={isRequired}> - <FormLabel htmlFor={name}>{label}</FormLabel> + {label && <FormLabel htmlFor={name}>{label}</FormLabel>} <Field {...field} id={name} diff --git a/frontend/src/bundles/common/components/sidebar/sidebar.tsx b/frontend/src/bundles/common/components/sidebar/sidebar.tsx index 57a09588f..cef1e94df 100644 --- a/frontend/src/bundles/common/components/sidebar/sidebar.tsx +++ b/frontend/src/bundles/common/components/sidebar/sidebar.tsx @@ -106,9 +106,29 @@ const Sidebar: React.FC<Properties> = ({ children }) => { label="My Avatar" /> </Link> - <Text color="background.600" variant="bodySmall"> - Assets - </Text> + <Link to={AppRoute.TEMPLATES}> + <SidebarItem + bg={activeButtonPage(AppRoute.TEMPLATES)} + icon={ + <Icon + as={IconName.TEMPLATE} + boxSize={5} + color={activeIconPage(AppRoute.TEMPLATES)} + /> + } + isCollapsed={isCollapsed} + label="Templates" + /> + </Link> + {isCollapsed ? ( + <Text color="background.600" variant="caption"> + Assets + </Text> + ) : ( + <Text color="background.600" variant="bodySmall"> + Assets + </Text> + )} <Link to={AppRoute.VOICES}> <SidebarItem bg={activeButtonPage(AppRoute.VOICES)} diff --git a/frontend/src/bundles/common/enums/app-route.enum.ts b/frontend/src/bundles/common/enums/app-route.enum.ts index 0e2ae676b..fd181a45d 100644 --- a/frontend/src/bundles/common/enums/app-route.enum.ts +++ b/frontend/src/bundles/common/enums/app-route.enum.ts @@ -7,6 +7,7 @@ const AppRoute = { ANY: '*', CREATE_AVATAR: '/create-avatar', VOICES: '/voices', + TEMPLATES: '/templates', } as const; export { AppRoute }; diff --git a/frontend/src/bundles/common/hooks/use-app-form/use-app-form.hook.ts b/frontend/src/bundles/common/hooks/use-app-form/use-app-form.hook.ts index 827d9e176..d7f8a0653 100644 --- a/frontend/src/bundles/common/hooks/use-app-form/use-app-form.hook.ts +++ b/frontend/src/bundles/common/hooks/use-app-form/use-app-form.hook.ts @@ -21,7 +21,7 @@ type Parameters<T extends FormValues = FormValues> = { initialValues: T; mode?: ValueOf<typeof ValidationMode>; validationSchema?: ValidationSchema; - onSubmit: FormConfig<T>['onSubmit']; + onSubmit?: FormConfig<T>['onSubmit']; }; type ReturnValue<T extends FormValues = FormValues> = ReturnType< @@ -32,7 +32,9 @@ const useAppForm = <T extends FormValues = FormValues>({ initialValues, mode = 'onSubmit', validationSchema, - onSubmit, + onSubmit = (values: T): void => { + values; + }, }: Parameters<T>): ReturnValue<T> => { const validateOnBlur = mode === ValidationMode.ALL ? true : mode === ValidationMode.ON_BLUR; diff --git a/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts b/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts index 924ca7c25..1dfe8d66f 100644 --- a/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts +++ b/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts @@ -12,6 +12,7 @@ import { faHeart, faHouse, faImage, + faMagnifyingGlass, faPause, faPen, faPlay, @@ -51,6 +52,7 @@ const Image = convertIcon(faImage); const Circle = convertIcon(faCircle); const HeartFill = convertIcon(faHeart); const HeartOutline = convertIcon(faHeartRegular); +const Magnifying = convertIcon(faMagnifyingGlass); export { BackwardStep, @@ -66,6 +68,7 @@ export { HeartOutline, House, Image, + Magnifying, Pause, Pen, Play, diff --git a/frontend/src/bundles/common/icons/icon-name.ts b/frontend/src/bundles/common/icons/icon-name.ts index d626ab033..0230f26c6 100644 --- a/frontend/src/bundles/common/icons/icon-name.ts +++ b/frontend/src/bundles/common/icons/icon-name.ts @@ -28,6 +28,7 @@ import { HeartOutline, House, Image, + Magnifying, Pause, Pen, Play, @@ -82,6 +83,7 @@ const IconName = { VOICE: Voice, HEART_FILL: HeartFill, HEART_OUTLINE: HeartOutline, + MAGNIFYING: Magnifying, } as const; export { IconName }; diff --git a/frontend/src/bundles/studio/components/timeline/item/item.tsx b/frontend/src/bundles/studio/components/timeline/item/item.tsx index d616a066c..3a8846052 100644 --- a/frontend/src/bundles/studio/components/timeline/item/item.tsx +++ b/frontend/src/bundles/studio/components/timeline/item/item.tsx @@ -50,7 +50,7 @@ const Item: React.FC<Properties> = ({ {...(type !== RowNames.BUTTON && listeners)} {...attributes} style={itemStyle} - zIndex={isDragging ? '100' : 'auto'} + zIndex={isDragging || selectedItem?.id === id ? '100' : 'auto'} onClick={onClick} data-id={id} > diff --git a/frontend/src/bundles/studio/helpers/are-all-scenes-with-scenes.helper.ts b/frontend/src/bundles/studio/helpers/are-all-scenes-with-scenes.helper.ts new file mode 100644 index 000000000..60271a87e --- /dev/null +++ b/frontend/src/bundles/studio/helpers/are-all-scenes-with-scenes.helper.ts @@ -0,0 +1,7 @@ +import { type Scene } from '../types/types.js'; + +function areAllScenesWithAvatar(scenes: Scene[]): boolean { + return scenes.every((scene) => scene.avatar !== undefined); +} + +export { areAllScenesWithAvatar }; diff --git a/frontend/src/bundles/studio/helpers/helpers.ts b/frontend/src/bundles/studio/helpers/helpers.ts index 00ac68474..7a14da2ba 100644 --- a/frontend/src/bundles/studio/helpers/helpers.ts +++ b/frontend/src/bundles/studio/helpers/helpers.ts @@ -1,5 +1,6 @@ export { addScene } from './add-scene.js'; export { addScript } from './add-script.js'; +export { areAllScenesWithAvatar } from './are-all-scenes-with-scenes.helper.js'; export { createDefaultAvatarFromRequest } from './create-default-avatar.js'; export { getDestinationPointerValue } from './get-destination-pointer-value.js'; export { getElementEnd } from './get-element-end.js'; diff --git a/frontend/src/bundles/studio/pages/studio.tsx b/frontend/src/bundles/studio/pages/studio.tsx index 65fd6e058..15dd38ece 100644 --- a/frontend/src/bundles/studio/pages/studio.tsx +++ b/frontend/src/bundles/studio/pages/studio.tsx @@ -46,7 +46,11 @@ import { VIDEO_SUBMIT_NOTIFICATION_ID, } from '../constants/constants.js'; import { NotificationMessage, NotificationTitle } from '../enums/enums.js'; -import { getVoicesConfigs, scenesExceedScripts } from '../helpers/helpers.js'; +import { + areAllScenesWithAvatar, + getVoicesConfigs, + scenesExceedScripts, +} from '../helpers/helpers.js'; import { selectVideoDataById } from '../store/selectors.js'; import { actions as studioActions } from '../store/studio.js'; @@ -93,15 +97,14 @@ const Studio: React.FC = () => { const handleConfirmSubmit = useCallback(() => { // TODO: REPLACE LOGIC WITH MULTIPLE SCENES - const scene = scenes[0]; + const script = scripts[0]; - if (!scene?.avatar || !script) { - notificationService.warn({ + if (!areAllScenesWithAvatar(scenes) || !script) { + return notificationService.warn({ id: SCRIPT_AND_AVATAR_ARE_REQUIRED, message: NotificationMessage.SCRIPT_AND_AVATAR_ARE_REQUIRED, title: NotificationTitle.SCRIPT_AND_AVATAR_ARE_REQUIRED, }); - return; } void dispatch(studioActions.generateAllScriptsSpeech()) @@ -158,7 +161,6 @@ const Studio: React.FC = () => { composition: { scenes, scripts: getVoicesConfigs(scripts), - // TODO : CHANGE TO ENUM videoOrientation: videoSize, }, name: videoName, diff --git a/frontend/src/bundles/template/components/components.ts b/frontend/src/bundles/template/components/components.ts new file mode 100644 index 000000000..7b03e2032 --- /dev/null +++ b/frontend/src/bundles/template/components/components.ts @@ -0,0 +1,2 @@ +export { CreationsSection } from './creations-section/creations-section.js'; +export { TemplatesSection } from './templates-section/templates-section.js'; diff --git a/frontend/src/bundles/template/components/creations-section/creations-section.tsx b/frontend/src/bundles/template/components/creations-section/creations-section.tsx new file mode 100644 index 000000000..8c71bb984 --- /dev/null +++ b/frontend/src/bundles/template/components/creations-section/creations-section.tsx @@ -0,0 +1,67 @@ +import { + Box, + Button, + Flex, + Heading, +} from '~/bundles/common/components/components.js'; +import { Circles, Dots } from '~/bundles/my-avatar/components/components.js'; +import { type Template } from '~/bundles/template/types/types.js'; + +import { TemplateCard } from '../template-card/template-card.js'; +import styles from './styles.module.css'; + +//TODO: Change with the backend information +const templates: Template[] = []; + +type Properties = { + onClick: () => void; +}; + +const CreationsSection: React.FC<Properties> = ({ onClick }) => { + return ( + <Box padding="17px 0"> + <Flex alignItems="center" marginBottom="9px"> + <Heading color="typography.900" variant="H3" marginRight="11px"> + Recent Creations + </Heading> + </Flex> + + {templates.length > 0 ? ( + <Box className={styles['horizontal']}> + {templates.map(({ id, ...template }) => ( + <Box + key={id} + flex="0 0 auto" + marginRight="20px" + width="250px" + > + <TemplateCard {...template} /> + </Box> + ))} + </Box> + ) : ( + <Flex + bg="white" + h="140px" + borderRadius="lg" + justify="center" + align="center" + overflow="hidden" + maxWidth="340px" + > + <Box w={{ base: '222px', sm: '222px' }} position="relative"> + <Circles /> + <Dots /> + <Button + label="Create Your First Template" + variant="outlined" + onClick={onClick} + /> + </Box> + </Flex> + )} + </Box> + ); +}; + +export { CreationsSection }; diff --git a/frontend/src/bundles/template/components/creations-section/styles.module.css b/frontend/src/bundles/template/components/creations-section/styles.module.css new file mode 100644 index 000000000..64d222997 --- /dev/null +++ b/frontend/src/bundles/template/components/creations-section/styles.module.css @@ -0,0 +1,6 @@ +.horizontal { + display: flex; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: #c1c1c1 #f1f1f1; +} diff --git a/frontend/src/bundles/template/components/template-card/styles.module.css b/frontend/src/bundles/template/components/template-card/styles.module.css new file mode 100644 index 000000000..fca3db35f --- /dev/null +++ b/frontend/src/bundles/template/components/template-card/styles.module.css @@ -0,0 +1,14 @@ +.play-button { + position: absolute; + background-color: white; + top: 5px; + right: 5px; +} + +.preview-image { + border-radius: 5px; + height: 100%; + width: 100%; + object-fit: cover; + object-position: top; +} diff --git a/frontend/src/bundles/template/components/template-card/template-card.tsx b/frontend/src/bundles/template/components/template-card/template-card.tsx new file mode 100644 index 000000000..a6e8cb657 --- /dev/null +++ b/frontend/src/bundles/template/components/template-card/template-card.tsx @@ -0,0 +1,107 @@ +import { + Badge, + Box, + Icon, + IconButton, + Image, + Text, +} from '~/bundles/common/components/components.js'; +import { useCallback, useState } from '~/bundles/common/hooks/hooks.js'; +import { IconName } from '~/bundles/common/icons/icons.js'; +import { PlayerModal } from '~/bundles/home/components/player-modal/player-modal.js'; +import { DeleteWarning } from '~/bundles/home/components/video-card/components/delete-warning.js'; + +import styles from './styles.module.css'; + +type Properties = { + name: string; + url: string | null; + previewUrl: string; +}; + +const TemplateCard: React.FC<Properties> = ({ name, url, previewUrl }) => { + const [isVideoModalOpen, setIsVideoModalOpen] = useState(false); + const [isWarningModalOpen, setIsWarningModalOpen] = useState(false); + + const handleIconClick = useCallback(() => { + if (url) { + return setIsVideoModalOpen(true); + } + }, [url]); + + const handleVideoModalClose = useCallback(() => { + setIsVideoModalOpen(false); + }, []); + + const handleWarningModalClose = useCallback(() => { + setIsWarningModalOpen(false); + }, []); + + const handleDelete = useCallback(() => { + handleWarningModalClose(); + }, [handleWarningModalClose]); + + return ( + <Box borderRadius="8px" bg="white" padding="7px"> + <Box + position="relative" + role="group" + height="115px" + overflow="hidden" + > + <Image + src={previewUrl} + alt="Video preview" + className={styles['preview-image']} + /> + + <IconButton + size="xs" + aria-label={url ? 'Play video' : 'Edit video'} + _groupHover={{ opacity: 1 }} + onClick={handleIconClick} + className={styles['play-button']} + icon={ + <Icon + as={url ? IconName.PLAY : IconName.PEN} + color="background.600" + /> + } + /> + </Box> + + <Box padding="7px 10px 5px 5px"> + <Text variant="button" color="typography.900"> + {name} + </Text> + </Box> + + <Badge + color="typography.900" + textTransform="none" + bg="background.300" + fontWeight="400" + padding="0 8px" + borderRadius="24px" + > + Advertisement + </Badge> + + {url && ( + <PlayerModal + videoUrl={url} + isOpen={isVideoModalOpen} + onClose={handleVideoModalClose} + /> + )} + + <DeleteWarning + isOpen={isWarningModalOpen} + onClose={handleWarningModalClose} + onDelete={handleDelete} + /> + </Box> + ); +}; + +export { TemplateCard }; diff --git a/frontend/src/bundles/template/components/templates-section/constants.ts b/frontend/src/bundles/template/components/templates-section/constants.ts new file mode 100644 index 000000000..d4ecb4109 --- /dev/null +++ b/frontend/src/bundles/template/components/templates-section/constants.ts @@ -0,0 +1,7 @@ +const DEFAULT_TEMPLATE_PAYLOAD = { + category: '', + search: '', + format: '', +}; + +export { DEFAULT_TEMPLATE_PAYLOAD }; diff --git a/frontend/src/bundles/template/components/templates-section/styles.module.css b/frontend/src/bundles/template/components/templates-section/styles.module.css new file mode 100644 index 000000000..f0654b1b6 --- /dev/null +++ b/frontend/src/bundles/template/components/templates-section/styles.module.css @@ -0,0 +1,11 @@ +.button { + width: 100%; + border-radius: 20px; + background-color: #f6f5fa; + color: #6b6b6b; +} + +.button.selected { + background-color: white; + color: #4a4a4a; +} diff --git a/frontend/src/bundles/template/components/templates-section/templates-section.tsx b/frontend/src/bundles/template/components/templates-section/templates-section.tsx new file mode 100644 index 000000000..c9b6c3801 --- /dev/null +++ b/frontend/src/bundles/template/components/templates-section/templates-section.tsx @@ -0,0 +1,132 @@ +import { + Box, + Button, + Flex, + FormProvider, + Heading, + Icon, + Input, + InputGroup, + InputLeftElement, + Select, + SimpleGrid, +} from '~/bundles/common/components/components.js'; +import { + useAppForm, + useCallback, + useState, +} from '~/bundles/common/hooks/hooks.js'; +import { IconName } from '~/bundles/common/icons/icons.js'; + +import { type Template } from '../../types/template.type.js'; +import { TemplateCard } from '../template-card/template-card.js'; +import { DEFAULT_TEMPLATE_PAYLOAD } from './constants.js'; + +//TODO Change with the backend information +const templates: Template[] = [ + { + id: '1', + name: 'My first avatar video', + url: 'https://d19jw8gcwb6nqj.cloudfront.net/renders/2ymzogrn5a/out.mp4', + previewUrl: + 'https://s3-alpha-sig.figma.com/img/f5bc/ae04/08301b8c7727dcf6209bc655b0dd7133?Expires=1728259200&Key-Pair-Id=APKAQ4GOSFWCVNEHN3O4&Signature=n5c0QxXVFRbXTmwIakB5EoU6FRSafJV4WqXFLHI6hp3XlikD8TfwCzovoEAxxj4WYBrm2k371A-NaX1PWZdVQbEYKzVT0ZtcXR8eRO39vQ0ZBFQL8J4Vdqps-XJc3Dau4im97u3wb-mrweKhwlDHiI9xN-~3-7Gk7nM6EYjfaVQU9T5j9-zP5RSqE3PBDTjZlpnIgCDhkTVFmWb6n2O3XZ3X85uKVl-6R0dLYkhv2qux~r1gespYmw3KQJesUpix5P3hsxpzk~WkiANM6dudib9Yapk2wG6u6ULIE1rtgjPNm6myG1bWV0dX0jEAsabMNn95WmelSEK6Pq3Q4fGrRg__', + }, +]; + +const TemplatesSection: React.FC = () => { + const [selectedFormat, setSelectedFormat] = useState< + 'landscape' | 'portrait' | null + >(null); + + const handleLandscapeClick = useCallback((): void => { + setSelectedFormat('landscape'); + }, []); + + const handlePortraitClick = useCallback((): void => { + setSelectedFormat('portrait'); + }, []); + + const form = useAppForm({ + initialValues: DEFAULT_TEMPLATE_PAYLOAD, + }); + + return ( + <Box padding="17px 0"> + <Flex + alignItems="center" + marginBottom="9px" + justifyContent="space-between" + > + <Heading color="typography.900" variant="H3" marginRight="11px"> + OutreachVids Template + </Heading> + <Flex flexDirection="row" gap="3" alignItems="center"> + <FormProvider value={form}> + <Select placeholder="Categories" name="language"> + <option value="option1">All Categories</option> + </Select> + <InputGroup> + <InputLeftElement pointerEvents="none"> + <Icon + as={IconName.MAGNIFYING} + color="background.600" + /> + </InputLeftElement> + <Input + name="name" + type="text" + placeholder="Search templates" + paddingLeft="30px" + /> + </InputGroup> + <Flex + flexDirection="row" + backgroundColor="#F6F5FA" + borderRadius="20px" + > + <Button + label="Landscape" + w="100%" + borderRadius="20px" + bg={ + selectedFormat === 'landscape' + ? 'white' + : '#F6F5FA' + } + color={ + selectedFormat === 'landscape' + ? 'background.600' + : 'typography.600' + } + onClick={handleLandscapeClick} + /> + <Button + label="Portrait" + w="100%" + borderRadius="20px" + bg={ + selectedFormat === 'portrait' + ? 'white' + : '#F6F5FA' + } + color={ + selectedFormat === 'portrait' + ? 'background.600' + : 'typography.600' + } + onClick={handlePortraitClick} + ></Button> + </Flex> + </FormProvider> + </Flex> + </Flex> + <SimpleGrid columns={{ sm: 2, md: 3, lg: 4 }} spacing="20px"> + {templates.map(({ id, ...template }) => ( + <TemplateCard key={id} {...template} /> + ))} + </SimpleGrid> + </Box> + ); +}; + +export { TemplatesSection }; diff --git a/frontend/src/bundles/template/pages/styles.module.css b/frontend/src/bundles/template/pages/styles.module.css new file mode 100644 index 000000000..a781376f5 --- /dev/null +++ b/frontend/src/bundles/template/pages/styles.module.css @@ -0,0 +1,8 @@ +.container { + height: calc(100vh - 75px); + padding: 25px; + flex-direction: column; + overflow: auto; + scrollbar-width: thin; + clip-path: inset(0 round 0 0.75rem 0 0); +} diff --git a/frontend/src/bundles/template/pages/templates.tsx b/frontend/src/bundles/template/pages/templates.tsx new file mode 100644 index 000000000..9e326cb35 --- /dev/null +++ b/frontend/src/bundles/template/pages/templates.tsx @@ -0,0 +1,68 @@ +import { + Box, + Button, + Flex, + Header, + Icon, + Sidebar, + Text, +} from '~/bundles/common/components/components.js'; +import { useCollapse } from '~/bundles/common/components/sidebar/hooks/use-collapse.hook.js'; +import { AppRoute } from '~/bundles/common/enums/enums.js'; +import { useCallback, useNavigate } from '~/bundles/common/hooks/hooks.js'; +import { IconName } from '~/bundles/common/icons/icons.js'; +import { + CreationsSection, + TemplatesSection, +} from '~/bundles/template/components/components.js'; + +import styles from './styles.module.css'; + +const Templates: React.FC = () => { + const { isCollapsed } = useCollapse(); + + const navigate = useNavigate(); + + const handleClick = useCallback(() => { + navigate(AppRoute.STUDIO); + }, [navigate]); + + return ( + <Box bg="background.900" minHeight="100vh"> + <Header /> + <Sidebar> + <Box + bg="background.900" + pr="25px" + w={ + isCollapsed + ? 'calc(100vw - 60px)' + : 'calc(100vw - 270px)' + } + > + <Flex + bg="background.50" + borderTopRadius="xl" + className={styles['container']} + > + <Flex justifyContent="space-between"> + <Text variant="title">Templates</Text> + <Button + leftIcon={<Icon as={IconName.ADD} />} + label="Create Template" + variant="outlined" + width="auto" + onClick={handleClick} + ></Button> + </Flex> + + <CreationsSection onClick={handleClick} /> + <TemplatesSection /> + </Flex> + </Box> + </Sidebar> + </Box> + ); +}; + +export { Templates }; diff --git a/frontend/src/bundles/template/types/template.type.ts b/frontend/src/bundles/template/types/template.type.ts new file mode 100644 index 000000000..5bf45cdc3 --- /dev/null +++ b/frontend/src/bundles/template/types/template.type.ts @@ -0,0 +1,8 @@ +type Template = { + id: string; + name: string; + url: string | null; + previewUrl: string; +}; + +export { type Template }; diff --git a/frontend/src/bundles/template/types/types.ts b/frontend/src/bundles/template/types/types.ts new file mode 100644 index 000000000..58b953e07 --- /dev/null +++ b/frontend/src/bundles/template/types/types.ts @@ -0,0 +1 @@ +export { type Template } from './template.type.js'; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 7e22c679b..eacee362f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -7,6 +7,7 @@ import { CreateAvatar } from '~/bundles/create-avatar/pages/create-avatar.js'; import { Home } from '~/bundles/home/pages/home.js'; import { MyAvatar } from '~/bundles/my-avatar/pages/my-avatar.js'; import { Studio } from '~/bundles/studio/pages/studio.js'; +import { Templates } from '~/bundles/template/pages/templates.js'; import { Voices } from '~/bundles/voices/pages/voices.js'; const routes = [ @@ -62,6 +63,14 @@ const routes = [ </ProtectedRoute> ), }, + { + path: AppRoute.TEMPLATES, + element: ( + <ProtectedRoute> + <Templates /> + </ProtectedRoute> + ), + }, { path: AppRoute.ANY, element: <NotFound />, diff --git a/package-lock.json b/package-lock.json index 04e0fa753..75034ef25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,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" @@ -84,6 +85,44 @@ "npm": "10.x.x" } }, + "backend/node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, "frontend": { "version": "1.0.0", "dependencies": { @@ -8174,6 +8213,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/@emnapi/runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", @@ -9142,6 +9190,348 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -14218,7 +14608,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -14247,7 +14636,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -21956,7 +22344,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dev": true, "dependencies": { "is-arrayish": "^0.3.1" } @@ -21964,8 +22351,7 @@ "node_modules/simple-swizzle/node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "dev": true + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/simple-update-notifier": { "version": "2.0.0", diff --git a/shared/src/bundles/avatar-videos/avatar-videos.ts b/shared/src/bundles/avatar-videos/avatar-videos.ts index 4fa52026b..4e221c8e2 100644 --- a/shared/src/bundles/avatar-videos/avatar-videos.ts +++ b/shared/src/bundles/avatar-videos/avatar-videos.ts @@ -12,4 +12,7 @@ export { type VideoCodec, type VideoFormat, } from './types/types.js'; -export { renderAvatarVideo as renderAvatarVideoValidationSchema } from './validation-schemas/validation-schemas.js'; +export { + compositionSchema as compositionValidationSchema, + renderAvatarVideo as renderAvatarVideoValidationSchema, +} from './validation-schemas/validation-schemas.js'; diff --git a/shared/src/bundles/avatar-videos/validation-schemas/render-avatar-video.validation-schema.ts b/shared/src/bundles/avatar-videos/validation-schemas/render-avatar-video.validation-schema.ts index 3d6e41562..dca795410 100644 --- a/shared/src/bundles/avatar-videos/validation-schemas/render-avatar-video.validation-schema.ts +++ b/shared/src/bundles/avatar-videos/validation-schemas/render-avatar-video.validation-schema.ts @@ -130,4 +130,4 @@ const renderAvatarVideo = z.object<GenerateAvatarVideoRequestValidationDto>({ videoId: z.string().uuid().optional(), }); -export { renderAvatarVideo }; +export { compositionSchema, renderAvatarVideo }; diff --git a/shared/src/bundles/avatar-videos/validation-schemas/validation-schemas.ts b/shared/src/bundles/avatar-videos/validation-schemas/validation-schemas.ts index e497a8621..f57417130 100644 --- a/shared/src/bundles/avatar-videos/validation-schemas/validation-schemas.ts +++ b/shared/src/bundles/avatar-videos/validation-schemas/validation-schemas.ts @@ -1 +1,4 @@ -export { renderAvatarVideo } from './render-avatar-video.validation-schema.js'; +export { + compositionSchema, + renderAvatarVideo, +} from './render-avatar-video.validation-schema.js'; diff --git a/shared/src/bundles/templates/enums/enums.ts b/shared/src/bundles/templates/enums/enums.ts new file mode 100644 index 000000000..3aecae376 --- /dev/null +++ b/shared/src/bundles/templates/enums/enums.ts @@ -0,0 +1 @@ +export { TemplateValidationErrorMessage } from './template-validation-error-message.enum.js'; diff --git a/shared/src/bundles/templates/enums/template-validation-error-message.enum.ts b/shared/src/bundles/templates/enums/template-validation-error-message.enum.ts new file mode 100644 index 000000000..4851cf96f --- /dev/null +++ b/shared/src/bundles/templates/enums/template-validation-error-message.enum.ts @@ -0,0 +1,5 @@ +const TemplateValidationErrorMessage = { + TEMPLATE_NAME_IS_REQUIRED: 'Template name is required', +}; + +export { TemplateValidationErrorMessage }; diff --git a/shared/src/bundles/templates/templates.ts b/shared/src/bundles/templates/templates.ts new file mode 100644 index 000000000..8e2c4700e --- /dev/null +++ b/shared/src/bundles/templates/templates.ts @@ -0,0 +1,13 @@ +export { TemplateValidationErrorMessage } from './enums/enums.js'; +export { + type CreateTemplateRequestDto, + type CreateTemplateResponseDto, + type GetTemplateRequestDto, + type GetTemplatesResponseDto, + type Template, + type UpdateTemplateRequestDto, +} from './types/types.js'; +export { + createTemplateValidationSchema, + updateTemplateValidationSchema, +} from './validation-schemas/validation-schemas.js'; diff --git a/shared/src/bundles/templates/types/create-template-request-dto.type.ts b/shared/src/bundles/templates/types/create-template-request-dto.type.ts new file mode 100644 index 000000000..ac0d47bbb --- /dev/null +++ b/shared/src/bundles/templates/types/create-template-request-dto.type.ts @@ -0,0 +1,8 @@ +import { type Composition } from 'shared'; + +type CreateTemplateRequestDto = { + name: string; + composition: Composition; +}; + +export { type CreateTemplateRequestDto }; diff --git a/shared/src/bundles/templates/types/create-template-response-dto.type.ts b/shared/src/bundles/templates/types/create-template-response-dto.type.ts new file mode 100644 index 000000000..4d04e1bda --- /dev/null +++ b/shared/src/bundles/templates/types/create-template-response-dto.type.ts @@ -0,0 +1,5 @@ +import { type Template } from './template.type.js'; + +type CreateTemplateResponseDto = Template; + +export { type CreateTemplateResponseDto }; diff --git a/shared/src/bundles/templates/types/get-template-request-dto.type.ts b/shared/src/bundles/templates/types/get-template-request-dto.type.ts new file mode 100644 index 000000000..562eb4389 --- /dev/null +++ b/shared/src/bundles/templates/types/get-template-request-dto.type.ts @@ -0,0 +1,5 @@ +type GetTemplateRequestDto = { + id: string; +}; + +export { type GetTemplateRequestDto }; diff --git a/shared/src/bundles/templates/types/get-templates-response-dto.type.ts b/shared/src/bundles/templates/types/get-templates-response-dto.type.ts new file mode 100644 index 000000000..ff0a0015f --- /dev/null +++ b/shared/src/bundles/templates/types/get-templates-response-dto.type.ts @@ -0,0 +1,7 @@ +import { type Template } from './template.type.js'; + +type GetTemplatesResponseDto = { + items: Template[]; +}; + +export { type GetTemplatesResponseDto }; diff --git a/shared/src/bundles/templates/types/template.type.ts b/shared/src/bundles/templates/types/template.type.ts new file mode 100644 index 000000000..ac157af94 --- /dev/null +++ b/shared/src/bundles/templates/types/template.type.ts @@ -0,0 +1,11 @@ +import { type Composition } from 'shared'; + +type Template = { + id: string; + userId: string | null; + composition: Composition; + previewUrl: string; + name: string; +}; + +export { type Template }; diff --git a/shared/src/bundles/templates/types/types.ts b/shared/src/bundles/templates/types/types.ts new file mode 100644 index 000000000..18ffc8454 --- /dev/null +++ b/shared/src/bundles/templates/types/types.ts @@ -0,0 +1,6 @@ +export { type CreateTemplateRequestDto } from './create-template-request-dto.type.js'; +export { type CreateTemplateResponseDto } from './create-template-response-dto.type.js'; +export { type GetTemplateRequestDto } from './get-template-request-dto.type.js'; +export { type GetTemplatesResponseDto } from './get-templates-response-dto.type.js'; +export { type Template } from './template.type.js'; +export { type UpdateTemplateRequestDto } from './update-template-request-dto.type.js'; diff --git a/shared/src/bundles/templates/types/update-template-request-dto.type.ts b/shared/src/bundles/templates/types/update-template-request-dto.type.ts new file mode 100644 index 000000000..797e8e73f --- /dev/null +++ b/shared/src/bundles/templates/types/update-template-request-dto.type.ts @@ -0,0 +1,5 @@ +import { type CreateTemplateRequestDto } from './create-template-request-dto.type.js'; + +type UpdateTemplateRequestDto = Partial<CreateTemplateRequestDto>; + +export { type UpdateTemplateRequestDto }; diff --git a/shared/src/bundles/templates/validation-schemas/create-template.validation-schema.ts b/shared/src/bundles/templates/validation-schemas/create-template.validation-schema.ts new file mode 100644 index 000000000..ee25b4461 --- /dev/null +++ b/shared/src/bundles/templates/validation-schemas/create-template.validation-schema.ts @@ -0,0 +1,18 @@ +import { compositionValidationSchema } from 'shared'; +import { z } from 'zod'; + +import { TemplateValidationErrorMessage } from '../enums/enums.js'; + +type CreateTemplateRequestValidationDto = { + name: z.ZodString; + composition: typeof compositionValidationSchema; +}; + +const createTemplate = z.object<CreateTemplateRequestValidationDto>({ + name: z.string().trim().min(1, { + message: TemplateValidationErrorMessage.TEMPLATE_NAME_IS_REQUIRED, + }), + composition: compositionValidationSchema, +}); + +export { createTemplate }; diff --git a/shared/src/bundles/templates/validation-schemas/update-template.validation-schema.ts b/shared/src/bundles/templates/validation-schemas/update-template.validation-schema.ts new file mode 100644 index 000000000..13f4c5394 --- /dev/null +++ b/shared/src/bundles/templates/validation-schemas/update-template.validation-schema.ts @@ -0,0 +1,5 @@ +import { createTemplate } from './create-template.validation-schema.js'; + +const updateTemplate = createTemplate.partial(); + +export { updateTemplate }; diff --git a/shared/src/bundles/templates/validation-schemas/validation-schemas.ts b/shared/src/bundles/templates/validation-schemas/validation-schemas.ts new file mode 100644 index 000000000..ad11ca28d --- /dev/null +++ b/shared/src/bundles/templates/validation-schemas/validation-schemas.ts @@ -0,0 +1,2 @@ +export { createTemplate as createTemplateValidationSchema } from './create-template.validation-schema.js'; +export { updateTemplate as updateTemplateValidationSchema } from './update-template.validation-schema.js'; diff --git a/shared/src/bundles/videos/types/create-video-request-dto.type.ts b/shared/src/bundles/videos/types/create-video-request-dto.type.ts index 8f5c16603..39689b8ee 100644 --- a/shared/src/bundles/videos/types/create-video-request-dto.type.ts +++ b/shared/src/bundles/videos/types/create-video-request-dto.type.ts @@ -1,4 +1,4 @@ -import { type Composition } from '../../avatar-videos/types/types.js'; +import { type Composition } from 'shared'; type CreateVideoRequestDto = { name: string; diff --git a/shared/src/bundles/videos/types/video-get-all-item-response-dto.type.ts b/shared/src/bundles/videos/types/video-get-all-item-response-dto.type.ts index 9e7287509..a85986e4e 100644 --- a/shared/src/bundles/videos/types/video-get-all-item-response-dto.type.ts +++ b/shared/src/bundles/videos/types/video-get-all-item-response-dto.type.ts @@ -1,4 +1,4 @@ -import { type Composition } from '../../avatar-videos/types/types.js'; +import { type Composition } from 'shared'; type VideoGetAllItemResponseDto = { id: string; diff --git a/shared/src/enums/api-path.enum.ts b/shared/src/enums/api-path.enum.ts index a3429e663..e271483eb 100644 --- a/shared/src/enums/api-path.enum.ts +++ b/shared/src/enums/api-path.enum.ts @@ -8,6 +8,7 @@ const ApiPath = { CHAT: '/chat', SPEECH: '/speech', AVATAR_VIDEO: '/avatar-video', + TEMPLATES: '/templates', } as const; export { ApiPath }; diff --git a/shared/src/enums/content-type.enum.ts b/shared/src/enums/content-type.enum.ts index 333af187e..22e011104 100644 --- a/shared/src/enums/content-type.enum.ts +++ b/shared/src/enums/content-type.enum.ts @@ -1,5 +1,6 @@ const ContentType = { JSON: 'application/json', + IMAGE: 'image/*', } as const; export { ContentType }; diff --git a/shared/src/index.ts b/shared/src/index.ts index af04bbfeb..24275cd53 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -12,6 +12,7 @@ export { type VideoCodec, type VideoFormat, AvatarVideosApiPath, + compositionValidationSchema, renderAvatarVideoValidationSchema, } from './bundles/avatar-videos/avatar-videos.js'; export { @@ -48,6 +49,17 @@ export { generateSpeechValidationSchema, SpeechApiPath, } from './bundles/speech/speech.js'; +export { + type CreateTemplateRequestDto, + type CreateTemplateResponseDto, + type GetTemplateRequestDto, + type GetTemplatesResponseDto, + type Template, + type UpdateTemplateRequestDto, + createTemplateValidationSchema, + TemplateValidationErrorMessage, + updateTemplateValidationSchema, +} from './bundles/templates/templates.js'; export { type UserGetAllItemResponseDto, type UserGetAllResponseDto,