initial commit
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user