import { Router } from "express"; import { randomUUID } from "crypto"; import multer from "multer"; import { S3Client, PutObjectCommand, GetObjectCommand, CreateBucketCommand, HeadBucketCommand, } from "@aws-sdk/client-s3"; import pg from "pg"; import { authRequired } from "../middleware/authRequired.js"; const { Pool } = pg; const router = Router(); const s3 = new S3Client({ region: process.env.S3_REGION || "us-east-1", endpoint: process.env.S3_ENDPOINT || "http://127.0.0.1:9000", forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY || "UN0-admin", secretAccessKey: process.env.S3_SECRET_KEY || "RAygtZHqGN49qKn", }, }); const BUCKET = process.env.S3_BUCKET || "uno-click"; const pool = new Pool({ host: process.env.PG_HOST || "127.0.0.1", port: Number(process.env.PG_PORT || 5432), database: process.env.PG_DATABASE || "n8n", user: process.env.PG_USER || "n8n", password: process.env.PG_PASSWORD, }); async function ensureBucket() { try { await s3.send(new HeadBucketCommand({ Bucket: BUCKET })); } catch { await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); } } ensureBucket().catch(console.error); // Сохраняем таблицу video_jobs для обратной совместимости (старые задания) pool.query(`CREATE TABLE IF NOT EXISTS video_jobs ( job_id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'queued', input_s3_key TEXT, output_s3_key TEXT, error_msg TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`).then(() => console.log("[media] video_jobs table ready")).catch(console.error); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith("video/")) cb(null, true); else cb(new Error("Only video files allowed")); }, }); // Рандомные параметры уникализации для ffmpeg-api function buildFFmpegArgs() { const r = (a, b) => Math.random() * (b - a) + a; const hue = r(0, 5).toFixed(1); const sat = r(0, 5).toFixed(1); const br = r(-0.05, 0.05).toFixed(2); const con = r(0.98, 1.05).toFixed(2); const satv = r(0.95, 1.1).toFixed(2); const noise = Math.round(r(10, 20)); const speed = r(0.97, 0.99).toFixed(3); const tempo = (1 / parseFloat(speed)).toFixed(3); const scale = r(0.94, 0.98).toFixed(2); const pad = (1 / parseFloat(scale)).toFixed(4); const vol = r(1.0, 1.1).toFixed(2); const crf = Math.round(r(20, 24)); return [ "-c:v", "libx264", "-preset", "medium", "-crf", String(crf), "-c:a", "aac", "-b:a", "128k", "-vf", [ `hue=s=${sat}:h=${hue}`, `eq=brightness=${br}:contrast=${con}:saturation=${satv}`, `noise=alls=${noise}:allf=t+u`, `setpts=${speed}*PTS`, `scale=iw*${scale}:ih*${scale}`, `pad=iw*${pad}:ih*${pad}:(ow-iw)/2:(oh-ih)/2`, ].join(","), "-af", `volume=${vol},atempo=${tempo}`, "-max_muxing_queue_size", "1024", ]; } // ─── POST /api/media/upload-video ───────────────────────────────────────────── // Принимает видеофайл, сохраняет в MinIO, привязывает к активному сценарию. // Прямой вызов FFmpeg удалён — запуск уникализации происходит через // POST /api/scenario/basic-unique/step/run-video → n8n → ffmpeg-api. router.post("/upload-video", authRequired, upload.single("video"), async (req, res) => { if (!req.file) return res.status(400).json({ error: "Field video required" }); const jobId = randomUUID(); const userId = req.user.id; const inputKey = `videos/input/${userId}/${jobId}.mp4`; const outputKey = `videos/output/${userId}/${jobId}.mp4`; // generationUuid может прийти в теле формы (FormData) или из JSON const generationUuid = req.body?.generationUuid || null; try { // 1. Сохраняем оригинальное видео в MinIO await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: inputKey, Body: req.file.buffer, ContentType: req.file.mimetype || "video/mp4", })); // 2. Если передан generationUuid — сохраняем шаг upload-video в БД, // чтобы шаг run-video мог прочитать ключи S3 и аргументы ffmpeg. if (generationUuid) { const stepPayload = { input_s3_key: inputKey, output_s3_key: outputKey, ffmpeg_args: buildFFmpegArgs(), }; await pool.query(` INSERT INTO uno_bff.generation_steps (generation_uuid, scenario_id, step_id, step_order, status, request_payload, started_at, finished_at) VALUES ($1, 'basic-unique', 'upload-video', 1, 'completed', $2, NOW(), NOW()) ON CONFLICT (generation_uuid, step_id) DO UPDATE SET status = 'completed', request_payload = $2, finished_at = NOW(), updated_at = NOW() `, [generationUuid, JSON.stringify(stepPayload)]); console.log(`[upload-video] Linked to generation ${generationUuid}, inputKey=${inputKey}`); } // 3. Отвечаем немедленно — FFmpeg НЕ вызываем здесь return res.json({ ok: true, job_id: jobId, input_s3_key: inputKey, output_s3_key: outputKey, generationUuid: generationUuid || null, }); } catch (err) { console.error("[upload-video]", err); if (!res.headersSent) res.status(500).json({ error: err.message }); } }); // ─── GET /api/media/status/:job_id ──────────────────────────────────────────── // Оставлен для обратной совместимости (старые задания через video_jobs). router.get("/status/:job_id", async (req, res) => { const { job_id } = req.params; try { const { rows } = await pool.query( "SELECT status, output_s3_key, error_msg FROM video_jobs WHERE job_id=$1", [job_id], ); if (!rows.length) return res.status(404).json({ error: "Job not found" }); const job = rows[0]; const PUBLIC_BFF = process.env.PUBLIC_BFF_URL || "https://uno-click.pip-test.ru"; const url = job.status === "done" ? `${PUBLIC_BFF}/api/media/download/${job_id}` : null; res.json({ status: job.status, url, error: job.error_msg || null }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ─── GET /api/media/download/:job_id ────────────────────────────────────────── // Оставлен для обратной совместимости (старые задания через video_jobs). router.get("/download/:job_id", async (req, res) => { const { job_id } = req.params; try { const { rows } = await pool.query( "SELECT status, output_s3_key FROM video_jobs WHERE job_id=$1", [job_id], ); if (!rows.length) return res.status(404).json({ error: "Job not found" }); if (rows[0].status !== "done") return res.status(409).json({ error: "Not ready yet" }); const s3Resp = await s3.send( new GetObjectCommand({ Bucket: BUCKET, Key: rows[0].output_s3_key }), ); res.setHeader("Content-Type", "video/mp4"); res.setHeader("Content-Disposition", `attachment; filename="uniqueized_${job_id}.mp4"`); if (s3Resp.ContentLength) res.setHeader("Content-Length", s3Resp.ContentLength); s3Resp.Body.pipe(res); } catch (err) { console.error("[download]", err); res.status(500).json({ error: err.message }); } }); // ─── GET /api/media/video/:generationUuid ───────────────────────────────────── // Стримит уникализованное видео из MinIO по generationUuid. // URL используется n8n result-воркфлоу в ответе на поллинг. router.get('/video/:generationUuid', async (req, res) => { const { generationUuid } = req.params; try { const { rows } = await pool.query( `SELECT response_payload FROM uno_bff.generation_steps WHERE generation_uuid = $1 AND step_id = 'run-video' AND status = 'completed' ORDER BY created_at DESC LIMIT 1`, [generationUuid] ); if (!rows.length) return res.status(404).json({ error: 'Video not ready or not found' }); const payload = rows[0].response_payload; const outputKey = typeof payload === 'string' ? JSON.parse(payload).output_s3_key : payload.output_s3_key; if (!outputKey) return res.status(404).json({ error: 'output_s3_key missing' }); const s3Resp = await s3.send( new GetObjectCommand({ Bucket: BUCKET, Key: outputKey }) ); res.setHeader('Content-Type', 'video/mp4'); res.setHeader('Content-Disposition', `attachment; filename=video_${generationUuid}.mp4`); if (s3Resp.ContentLength) res.setHeader('Content-Length', s3Resp.ContentLength); s3Resp.Body.pipe(res); } catch (err) { console.error('[video-by-generation]', err); if (!res.headersSent) res.status(500).json({ error: err.message }); } }); export default router;