From c3a80be648e36afb11d7465795b1fb643f9c5992 Mon Sep 17 00:00:00 2001 From: muhammadali-imran Date: Wed, 14 Oct 2026 01:41:28 +0500 Subject: [PATCH 1/5] Feature: responsive video windows. --- backend/src/docs/openapi.js | 8 + .../validators/demoVideoSchemas.js | 2 + .../middlewares/validators/videoSchemas.js | 4 + backend/src/models/DemoVideo.js | 2 + backend/src/models/Video.js | 3 + backend/src/services/demoVideoService.js | 2 +- backend/src/services/videoService.js | 2 +- frontend/package-lock.json | 32 ++++ frontend/package.json | 2 + frontend/src/components/BasePlayer.jsx | 41 ++-- frontend/src/components/RecorderPanel.jsx | 4 +- frontend/src/components/ResponsiveVideo.jsx | 36 ++++ frontend/src/components/TrimSlider.jsx | 9 +- frontend/src/components/VideoPrep.jsx | 177 ++++++++++++++++++ frontend/src/pages/Demo.jsx | 7 +- frontend/src/pages/Player.jsx | 9 +- frontend/src/pages/RecordingEditor.jsx | 51 ++--- frontend/src/pages/admin/AdminDemoVideo.jsx | 45 ++--- frontend/src/pages/admin/AdminVideos.jsx | 49 ++--- frontend/src/services/api.js | 8 +- frontend/src/styles/admin.css | 4 +- frontend/src/styles/landing.css | 2 +- frontend/src/styles/player.css | 41 ++-- frontend/src/utils/media.js | 35 +++- frontend/src/utils/processVideo.js | 71 +++++++ frontend/vite.config.js | 4 + 26 files changed, 527 insertions(+), 123 deletions(-) create mode 100644 frontend/src/components/ResponsiveVideo.jsx create mode 100644 frontend/src/components/VideoPrep.jsx create mode 100644 frontend/src/utils/processVideo.js diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 7304966..0db3717 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -113,6 +113,8 @@ export const openapi = { categoryId: { type: 'string', nullable: true }, order: { type: 'integer' }, durationSec: { type: 'number' }, + width: { type: 'integer', description: 'Displayed width in pixels; 0 when unknown.' }, + height: { type: 'integer', description: 'Displayed height in pixels; 0 when unknown.' }, videoUrl: { type: 'string' }, poster: { type: 'string', nullable: true }, size: { type: 'integer' }, @@ -213,6 +215,8 @@ export const openapi = { categoryId: { type: 'string', nullable: true, pattern: '^[a-f\\d]{24}$' }, status: { type: 'string', enum: ['draft', 'published'], default: 'published' }, durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 }, + width: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, + height: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, }, }, DemoVideo: { @@ -222,6 +226,8 @@ export const openapi = { id: { type: 'string', enum: ['current'] }, title: { type: 'string', maxLength: 120 }, durationSec: { type: 'number' }, + width: { type: 'integer' }, + height: { type: 'integer' }, videoUrl: { type: 'string' }, size: { type: 'integer' }, }, @@ -233,6 +239,8 @@ export const openapi = { video: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV video.' }, title: { type: 'string', minLength: 1, maxLength: 120 }, durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 }, + width: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, + height: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, }, }, Submission: { diff --git a/backend/src/middlewares/validators/demoVideoSchemas.js b/backend/src/middlewares/validators/demoVideoSchemas.js index ddf9a9a..eb3a003 100644 --- a/backend/src/middlewares/validators/demoVideoSchemas.js +++ b/backend/src/middlewares/validators/demoVideoSchemas.js @@ -3,4 +3,6 @@ import { z } from 'zod' export const uploadDemoVideoSchema = z.object({ title: z.string({ error: 'Give the demo video a title.' }).trim().min(1, 'Give the demo video a title.').max(120, 'Keep the title under 120 characters.'), durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), + width: z.coerce.number().int().min(0).max(16384).default(0), + height: z.coerce.number().int().min(0).max(16384).default(0), }) diff --git a/backend/src/middlewares/validators/videoSchemas.js b/backend/src/middlewares/validators/videoSchemas.js index f17a5df..412a8b5 100644 --- a/backend/src/middlewares/validators/videoSchemas.js +++ b/backend/src/middlewares/validators/videoSchemas.js @@ -7,11 +7,15 @@ const status = z.enum(VIDEO_STATUS_LIST, { error: `Status must be one of: ${VIDE // An empty string (from a form's "Unassigned" option) means no category. const categoryId = z.preprocess((value) => (value === '' ? null : value), objectId('category').nullable()) +const pixels = z.coerce.number({ error: 'Size must be a number.' }).int().min(0).max(16384).default(0) + export const createVideoSchema = z.object({ title, status: status.default(VIDEO_STATUS.PUBLISHED), categoryId: categoryId.optional().default(null), durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), + width: pixels, + height: pixels, }) export const updateVideoSchema = z.object({ diff --git a/backend/src/models/DemoVideo.js b/backend/src/models/DemoVideo.js index 313c849..2103c8b 100644 --- a/backend/src/models/DemoVideo.js +++ b/backend/src/models/DemoVideo.js @@ -7,6 +7,8 @@ const demoVideoSchema = new mongoose.Schema({ _id: { type: String, default: 'current' }, title: { type: String, required: true, trim: true, maxlength: 120 }, durationSec: { type: Number, default: 0, min: 0 }, + width: { type: Number, default: 0, min: 0 }, + height: { type: Number, default: 0, min: 0 }, videoFile: { type: storedFileSchema, required: true }, }, { timestamps: true, diff --git a/backend/src/models/Video.js b/backend/src/models/Video.js index 114d342..041c4c3 100644 --- a/backend/src/models/Video.js +++ b/backend/src/models/Video.js @@ -9,6 +9,9 @@ const videoSchema = new mongoose.Schema({ category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', default: null }, order: { type: Number, default: 1, min: 0 }, durationSec: { type: Number, default: 0, min: 0 }, + // Displayed size in pixels as the browser reported it when the admin prepared the file; 0 means unknown (older uploads). + width: { type: Number, default: 0, min: 0 }, + height: { type: Number, default: 0, min: 0 }, videoFile: { type: storedFileSchema, required: true }, posterFile: { type: storedFileSchema, default: null }, createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, diff --git a/backend/src/services/demoVideoService.js b/backend/src/services/demoVideoService.js index fd05711..1f42f25 100644 --- a/backend/src/services/demoVideoService.js +++ b/backend/src/services/demoVideoService.js @@ -22,7 +22,7 @@ export const demoVideoService = { try { const current = await demoVideoRepo.find() - const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, videoFile }) + const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, width: input.width, height: input.height, videoFile }) await storageService.remove(current?.videoFile) return saved } catch (error) { diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js index 59aca85..d18bd5f 100644 --- a/backend/src/services/videoService.js +++ b/backend/src/services/videoService.js @@ -69,7 +69,7 @@ export const videoService = { } const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 return await videoRepo.create({ - title: input.title, status: input.status, durationSec: input.durationSec, + title: input.title, status: input.status, durationSec: input.durationSec, width: input.width, height: input.height, category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id, }) } catch (error) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f79c360..d0b6d6a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "1.0.3", "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -422,6 +424,36 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", + "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==", + "license": "MIT", + "dependencies": { + "@ffmpeg/types": "^0.12.4" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz", + "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==", + "license": "MIT", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz", + "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==", + "license": "MIT", + "engines": { + "node": ">=18.x" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5c318ed..a223623 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "snap": "node scripts/runReactSnap.js" }, "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/src/components/BasePlayer.jsx b/frontend/src/components/BasePlayer.jsx index 5d02dc5..e2c6de1 100644 --- a/frontend/src/components/BasePlayer.jsx +++ b/frontend/src/components/BasePlayer.jsx @@ -1,10 +1,12 @@ import { useRef, useState } from 'react' import { formatTime } from '../utils/format' +import { aspectOf } from '../utils/media' +import ResponsiveVideo from './ResponsiveVideo' const SPEEDS = [0.5, 0.75, 1] /** Base video player: play/pause, replay, seek, speed and fullscreen. The parent owns the