From 7bfdae0519823d1b1979c196e9a50edd3ab096f5 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Tue, 7 Jul 2026 10:44:51 +0200 Subject: [PATCH] updated dependencies --- backend/package-lock.json | 43 +-- backend/package.json | 2 - backend/src/services/videoProcessing.ts | 289 ++++++++++++------- documentation/deployment-vm.md | 4 +- documentation/project-improvement-todo.ru.md | 53 ---- frontend/package-lock.json | 20 +- frontend/package.json | 2 +- 7 files changed, 206 insertions(+), 207 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index f8c13d5..2b22ab2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,7 +20,6 @@ "express": "^4.22.2", "ffmpeg-static": "^5.2.0", "ffprobe-static": "^3.1.0", - "fluent-ffmpeg": "^2.1.3", "helmet": "^8.0.0", "joi": "^17.13.0", "json2csv": "^5.0.7", @@ -47,7 +46,6 @@ "@types/express": "^4.17.25", "@types/express-serve-static-core": "^4.19.8", "@types/ffprobe-static": "^2.0.3", - "@types/fluent-ffmpeg": "^2.1.28", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", @@ -2300,16 +2298,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/fluent-ffmpeg": { - "version": "2.1.28", - "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz", - "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -3517,11 +3505,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -5538,19 +5521,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fluent-ffmpeg": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", - "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", - "license": "MIT", - "dependencies": { - "async": "^0.2.9", - "which": "^1.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -6667,6 +6637,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jju": { @@ -9831,18 +9802,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index b451049..c3a16db 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,7 +43,6 @@ "express": "^4.22.2", "ffmpeg-static": "^5.2.0", "ffprobe-static": "^3.1.0", - "fluent-ffmpeg": "^2.1.3", "helmet": "^8.0.0", "joi": "^17.13.0", "json2csv": "^5.0.7", @@ -85,7 +84,6 @@ "@types/express": "^4.17.25", "@types/express-serve-static-core": "^4.19.8", "@types/ffprobe-static": "^2.0.3", - "@types/fluent-ffmpeg": "^2.1.28", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", diff --git a/backend/src/services/videoProcessing.ts b/backend/src/services/videoProcessing.ts index 6bfa961..327db3e 100644 --- a/backend/src/services/videoProcessing.ts +++ b/backend/src/services/videoProcessing.ts @@ -6,11 +6,11 @@ */ import { promises as fs } from 'fs'; +import { spawn } from 'node:child_process'; import { createRequire } from 'node:module'; import os from 'os'; import path from 'path'; -import ffmpeg from 'fluent-ffmpeg'; import { path as ffprobePath } from 'ffprobe-static'; import config from '../config.ts'; @@ -22,11 +22,6 @@ const loadCommonJsModule = createRequire(import.meta.url); const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static'); const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null; -if (ffmpegPath) { - ffmpeg.setFfmpegPath(ffmpegPath); -} -ffmpeg.setFfprobePath(ffprobePath); - let ffmpegQueueTail: Promise = Promise.resolve(); let queuedFfmpegJobs = 0; let ffmpegJobSequence = 0; @@ -41,6 +36,27 @@ const reverseVideoBreaker = new CircuitBreaker({ const FFMPEG_REVERSE_TIMEOUT_MS = config.resilience.ffmpeg.reverseTimeoutMs; const FFPROBE_TIMEOUT_MS = config.resilience.ffmpeg.ffprobeTimeoutMs; +interface MediaProbeStream { + codec_type?: string | undefined; + duration?: string | number | undefined; + width?: string | number | undefined; + height?: string | number | undefined; + avg_frame_rate?: string | number | undefined; + r_frame_rate?: string | number | undefined; +} + +interface MediaProbeOutput { + streams?: MediaProbeStream[] | undefined; + format?: { + duration?: string | number | undefined; + } | undefined; +} + +interface ProcessResult { + stdout: string; + stderr: string; +} + function parseFrameRate(value: unknown): number | null { if (!value) { return null; @@ -86,6 +102,75 @@ function toError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)); } +function isMediaProbeOutput(value: unknown): value is MediaProbeOutput { + return Boolean(value) && typeof value === 'object'; +} + +function getFfmpegExecutablePath(): string { + if (!ffmpegPath) { + throw new Error('Bundled FFmpeg binary is unavailable'); + } + return ffmpegPath; +} + +async function runProcess( + executablePath: string, + args: string[], + options: { timeoutMs: number; timeoutMessage: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executablePath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const finish = (error: Error | null, result?: ProcessResult): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error) { + reject(error); + return; + } + resolve(result ?? { stdout, stderr }); + }; + + const timeout = setTimeout(() => { + const error = new Error(options.timeoutMessage); + child.kill('SIGKILL'); + finish(error); + }, options.timeoutMs); + timeout.unref(); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (error) => finish(error)); + child.on('close', (code, signal) => { + if (code === 0) { + finish(null, { stdout, stderr }); + return; + } + + finish( + new Error( + `Process failed: ${executablePath} exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`, + ), + ); + }); + }); +} + async function enqueueFfmpegJob( jobName: string, runJob: FfmpegJobRunner, @@ -167,59 +252,44 @@ async function reverseVideoWithoutQueue( 'Starting video reversal', ); - await new Promise((resolve, reject) => { - const command = ffmpeg(inputPath) - .outputOptions([ - '-vf', - 'reverse', - '-af', - 'areverse', - '-c:v', - 'libx264', - '-preset', - 'fast', - '-crf', - '23', - '-c:a', - 'aac', - '-threads', - '1', - '-movflags', - '+faststart', - ]) - .output(outputPath) - .on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command')) - .on('progress', (progress) => { - if (progress.percent) { - logger.debug({ percent: progress.percent }, 'FFmpeg progress'); - } - }) - .on('end', () => { - clearTimeout(timeout); - logger.info({ outputPath }, 'Video reversal complete'); - resolve(); - }) - .on('error', (err, stdout, stderr) => { - clearTimeout(timeout); - logger.error({ err, stdout, stderr }, 'FFmpeg error'); - reject(err); - }); + const ffmpegArgs = [ + '-y', + '-i', + inputPath, + '-vf', + 'reverse', + '-af', + 'areverse', + '-c:v', + 'libx264', + '-preset', + 'fast', + '-crf', + '23', + '-c:a', + 'aac', + '-threads', + '1', + '-movflags', + '+faststart', + outputPath, + ]; - const timeout = setTimeout(() => { - const error = new Error( - `FFmpeg reverse video timed out after ${FFMPEG_REVERSE_TIMEOUT_MS}ms`, - ); - logger.error( - { err: error, inputPath, outputPath }, - 'FFmpeg reverse video timeout', - ); - command.kill('SIGKILL'); - reject(error); - }, FFMPEG_REVERSE_TIMEOUT_MS); - timeout.unref(); + logger.debug( + { cmd: [getFfmpegExecutablePath(), ...ffmpegArgs].join(' ') }, + 'FFmpeg command', + ); - command.run(); - }); + try { + await runProcess(getFfmpegExecutablePath(), ffmpegArgs, { + timeoutMs: FFMPEG_REVERSE_TIMEOUT_MS, + timeoutMessage: `FFmpeg reverse video timed out after ${FFMPEG_REVERSE_TIMEOUT_MS}ms`, + }); + logger.info({ outputPath }, 'Video reversal complete'); + } catch (err) { + logger.error({ err, inputPath, outputPath }, 'FFmpeg error'); + throw err; + } const reversedBuffer = await fs.readFile(outputPath); let outputMetadata: MediaMetadata | null = null; @@ -253,59 +323,74 @@ async function reverseVideoWithoutQueue( } async function probeMediaMetadata(filePath: string): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject( - new Error(`FFprobe timed out after ${FFPROBE_TIMEOUT_MS}ms: ${filePath}`), - ); - }, FFPROBE_TIMEOUT_MS); - timeout.unref(); + const result = await runProcess( + ffprobePath, + [ + '-v', + 'error', + '-print_format', + 'json', + '-show_format', + '-show_streams', + filePath, + ], + { + timeoutMs: FFPROBE_TIMEOUT_MS, + timeoutMessage: `FFprobe timed out after ${FFPROBE_TIMEOUT_MS}ms: ${filePath}`, + }, + ); - ffmpeg.ffprobe(filePath, (err: unknown, metadata) => { - clearTimeout(timeout); - if (err) { - reject(toError(err)); - return; - } + let metadata: MediaProbeOutput; + try { + const parsedMetadata: unknown = JSON.parse(result.stdout); + if (!isMediaProbeOutput(parsedMetadata)) { + throw new Error('FFprobe output is not a JSON object'); + } + metadata = parsedMetadata; + } catch (error) { + throw new Error(`Failed to parse FFprobe metadata: ${toError(error).message}`); + } - const videoStream = - metadata.streams.find((stream) => stream.codec_type === 'video') ?? - null; - const audioStream = - metadata.streams.find((stream) => stream.codec_type === 'audio') ?? - null; - const primaryStream = videoStream ?? audioStream; - const formatDuration = Number(metadata.format.duration); - const streamDuration = Number(primaryStream?.duration); - const durationSec = - Number.isFinite(formatDuration) && formatDuration > 0 - ? formatDuration - : Number.isFinite(streamDuration) && streamDuration > 0 - ? streamDuration - : null; + const streams = metadata.streams ?? []; + const videoStream = + streams.find((stream) => stream.codec_type === 'video') ?? null; + const audioStream = + streams.find((stream) => stream.codec_type === 'audio') ?? null; + const primaryStream = videoStream ?? audioStream; + const formatDuration = Number(metadata.format?.duration); + const streamDuration = Number(primaryStream?.duration); + const durationSec = + Number.isFinite(formatDuration) && formatDuration > 0 + ? formatDuration + : Number.isFinite(streamDuration) && streamDuration > 0 + ? streamDuration + : null; - const widthPx = Number(videoStream?.width); - const heightPx = Number(videoStream?.height); - const frameRate = - parseFrameRate(videoStream?.avg_frame_rate) ?? - parseFrameRate(videoStream?.r_frame_rate); + const widthPx = Number(videoStream?.width); + const heightPx = Number(videoStream?.height); + const frameRate = + parseFrameRate(videoStream?.avg_frame_rate) ?? + parseFrameRate(videoStream?.r_frame_rate); - resolve({ - durationSec, - widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null, - heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null, - frameRate, - }); - }); - }); + return { + durationSec, + widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null, + heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null, + frameRate, + }; } async function isFFmpegAvailable(): Promise { - return new Promise((resolve) => { - ffmpeg.getAvailableFormats((err: unknown) => { - resolve(!err); + try { + await runProcess(getFfmpegExecutablePath(), ['-hide_banner', '-formats'], { + timeoutMs: FFPROBE_TIMEOUT_MS, + timeoutMessage: `FFmpeg availability check timed out after ${FFPROBE_TIMEOUT_MS}ms`, }); - }); + return true; + } catch (err) { + logger.warn({ err }, 'FFmpeg availability check failed'); + return false; + } } export { isFFmpegAvailable, probeMediaMetadata, reverseVideo }; diff --git a/documentation/deployment-vm.md b/documentation/deployment-vm.md index 862b84c..b67b32b 100644 --- a/documentation/deployment-vm.md +++ b/documentation/deployment-vm.md @@ -294,8 +294,8 @@ goal is only to restore the web app. Use PM2/systemd for process recovery. ## Node Version -The project requirement is Node.js 20.x LTS. Some standard VMs may report -`/usr/bin/node` as Node 22 in PM2. If startup fails after a system update, +The project requirement is Node.js 24.x LTS. Some standard VMs may report +a different `/usr/bin/node` in PM2. If startup fails after a system update, verify: ```bash diff --git a/documentation/project-improvement-todo.ru.md b/documentation/project-improvement-todo.ru.md index 36cae06..723a75e 100644 --- a/documentation/project-improvement-todo.ru.md +++ b/documentation/project-improvement-todo.ru.md @@ -42,53 +42,6 @@ TODO: - Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy. - Заменить frontend `jsonwebtoken` decode на `jwt-decode` или `/auth/me`. -## P1 - Build и зависимости - -### Package manager - -Решение: для root/frontend/backend используется только `npm`. -Источник истины для dependency graph: `package-lock.json` в `frontend/` и `backend/`. -Команды установки/deploy остаются на `npm ci` / `npm install` и `npm run ...`. - -TODO: - -- [x] Выбрать один package manager для frontend/backend: `npm`. -- [x] Удалить лишний lockfile после проверки: лишних `yarn.lock` / `pnpm-lock.yaml` нет, остаются только `package-lock.json`. -- [x] Обновить docs/deployment commands: текущие docs уже используют `npm`. - -### Dependencies LTS alignment и obsolete package replacement - -Зависимости нужно обновлять осознанно, чтобы не закреплять старые toolchain limitations и устаревшие библиотеки. - -TODO: - -- Проверить текущие frontend/backend dependencies и devDependencies на совместимость с выбранным Node LTS. -- Обновить TypeScript, ESLint, `@typescript-eslint/*`, Next.js/React tooling и backend runtime tooling до актуальных LTS/stable versions. -- Найти deprecated, unsupported и obsolete packages через package manager audit/outdated/deprecation warnings, npm metadata, release notes и project documentation. -- Для deprecated/unsupported/obsolete packages выбрать actively maintained mainstream alternatives с широкой adoption, свежими releases, нормальной TypeScript/ESM support и понятным migration path. -- Для каждой замены зафиксировать причину: что именно deprecated/unsupported/obsolete, какой replacement выбран, какие breaking changes ожидаются. -- Проверить DB scripts, migrations/seeders, test runner и production start после dependency updates. -- Не делать blind major upgrades без changelog/release notes и rollback plan. - -### Node version - -TODO: - -- Зафиксировать выбранный Node LTS через `backend/package.json` `engines.node`. -- Согласовать `engines.node`. -- Обновить `@types/node` во frontend, когда будет удобно. - -### CI/minimal checks - -Для небольшого проекта достаточно минимального gate: - -- frontend build; -- frontend lint/typecheck, если проходит; -- backend lint; -- smoke checklist перед deploy. - -Audit/outdated/license checks делать периодически, не обязательно blocking на каждый PR. - ## P2 - Security hardening ### CSP @@ -101,12 +54,6 @@ TODO: - Если будет время, включить report-only CSP на stage. - Enforced CSP делать только после проверки runtime presentations. -## P2 - Документация - -TODO: - -- Обновить docs только для реально изменённых workflows/API/schema. - ## Рекомендуемый порядок 1. Добавить validation для самых рискованных endpoints и запретить новые routes без validation. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3ccc27b..04daa6a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,7 +47,7 @@ "@tailwindcss/forms": "^0.5.7", "@types/file-saver": "^2.0.7", "@types/lodash": "^4.17.24", - "@types/node": "18.7.16", + "@types/node": "^24.13.2", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/parser": "^8.62.1", @@ -2610,11 +2610,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.7.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.16.tgz", - "integrity": "sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } }, "node_modules/@types/parse-json": { "version": "4.0.2", @@ -9065,6 +9068,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c76d07c..db9fbac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ "@tailwindcss/forms": "^0.5.7", "@types/file-saver": "^2.0.7", "@types/lodash": "^4.17.24", - "@types/node": "18.7.16", + "@types/node": "^24.13.2", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/parser": "^8.62.1",