388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import express from "express";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import crypto from "crypto";
|
|
import { createServer as createViteServer } from "vite";
|
|
import multer from "multer";
|
|
|
|
// ═══ Uploads directory ═══
|
|
const UPLOADS_DIR = process.env.BRADLY_UPLOADS_DIR || path.join(process.cwd(), "uploads");
|
|
if (!fs.existsSync(UPLOADS_DIR)) {
|
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
}
|
|
|
|
// ─── Multer: memory storage for transcription (existing) ───
|
|
const memoryUpload = multer({ storage: multer.memoryStorage() });
|
|
|
|
// ─── Multer: disk storage for persistent media uploads ───
|
|
const diskStorage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
|
|
filename: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname) || '.bin';
|
|
const id = crypto.randomUUID();
|
|
cb(null, `${id}${ext}`);
|
|
},
|
|
});
|
|
|
|
const mediaUpload = multer({
|
|
storage: diskStorage,
|
|
limits: {
|
|
fileSize: 50 * 1024 * 1024, // 50 MB max
|
|
},
|
|
fileFilter: (_req, file, cb) => {
|
|
const allowed = /^(image|video|audio)\//;
|
|
if (allowed.test(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error(`Unsupported file type: ${file.mimetype}`));
|
|
}
|
|
},
|
|
});
|
|
|
|
async function startServer() {
|
|
const app = await createExpressApp();
|
|
const PORT = 3000;
|
|
app.listen(PORT, "0.0.0.0", () => {
|
|
console.log(`Server running on http://localhost:${PORT}`);
|
|
});
|
|
}
|
|
|
|
export async function createExpressApp() {
|
|
const app = express();
|
|
const PORT = 3000;
|
|
|
|
// Add JSON parser with generous limit for render payloads (timelineElements can be large)
|
|
app.use(express.json({ limit: '50mb' }));
|
|
|
|
// ═══ Dynamic Workspace Serving ═══
|
|
let activeWorkspacePath = "";
|
|
|
|
app.post("/api/config/workspace", express.json(), (req, res) => {
|
|
activeWorkspacePath = req.body.path;
|
|
res.json({ success: true, path: activeWorkspacePath });
|
|
});
|
|
|
|
app.use("/workspace", (req, res, next) => {
|
|
if (activeWorkspacePath) {
|
|
const reqPath = decodeURIComponent(req.path);
|
|
const fullPath = path.join(activeWorkspacePath, reqPath);
|
|
// Validate path traversal
|
|
if (!fullPath.startsWith(activeWorkspacePath)) {
|
|
return res.status(403).send("Forbidden");
|
|
}
|
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
return res.sendFile(fullPath);
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Upload an asset directly to a brand's workspace folder
|
|
app.post("/api/upload/brand", mediaUpload.single("file"), (req, res) => {
|
|
try {
|
|
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
|
const { brandId, workspacePath } = req.body;
|
|
if (!brandId || !workspacePath) {
|
|
return res.status(400).json({ error: "brandId and workspacePath required" });
|
|
}
|
|
|
|
const brandDir = path.join(workspacePath, brandId, "brand");
|
|
if (!fs.existsSync(brandDir)) {
|
|
fs.mkdirSync(brandDir, { recursive: true });
|
|
}
|
|
|
|
// Move file from UPLOADS_DIR to brandDir
|
|
const ext = path.extname(req.file.originalname) || '';
|
|
const finalFilename = `${crypto.randomUUID()}${ext}`;
|
|
const finalPath = path.join(brandDir, finalFilename);
|
|
|
|
fs.renameSync(req.file.path, finalPath);
|
|
|
|
// Return the public URL that routes through our dynamic /workspace middleware
|
|
const publicUrl = `http://localhost:3000/workspace/${brandId}/brand/${finalFilename}`;
|
|
res.json({ url: publicUrl, path: finalPath });
|
|
} catch (error) {
|
|
console.error("Brand upload error:", error);
|
|
res.status(500).json({ error: "Upload failed" });
|
|
}
|
|
});
|
|
|
|
// ═══ Serve uploaded media files ═══
|
|
app.use("/api/media", express.static(UPLOADS_DIR, {
|
|
maxAge: "1d",
|
|
immutable: true,
|
|
}));
|
|
|
|
// ═══ Upload media file (persistent storage) ═══
|
|
app.post("/api/upload", mediaUpload.single("file"), (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: "No file provided" });
|
|
}
|
|
|
|
const url = `/api/media/${req.file.filename}`;
|
|
console.log(`📁 Uploaded: ${req.file.originalname} → ${url} (${(req.file.size / 1024).toFixed(1)} KB)`);
|
|
|
|
res.json({
|
|
url,
|
|
originalName: req.file.originalname,
|
|
size: req.file.size,
|
|
mimetype: req.file.mimetype,
|
|
});
|
|
});
|
|
|
|
// ═══ Transcription (existing) ═══
|
|
app.post("/api/transcribe", memoryUpload.single('file'), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: "No file provided" });
|
|
}
|
|
|
|
if (!process.env.GROQ_API_KEY) {
|
|
return res.status(500).json({ error: "GROQ_API_KEY not configured" });
|
|
}
|
|
|
|
const blob = new Blob([req.file.buffer], { type: req.file.mimetype || "audio/mpeg" });
|
|
const formData = new FormData();
|
|
formData.append("file", blob, req.file.originalname || "audio.mp3");
|
|
formData.append("model", "whisper-large-v3");
|
|
formData.append("response_format", "verbose_json");
|
|
formData.append("timestamp_granularities[]", "word");
|
|
|
|
const response = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${process.env.GROQ_API_KEY}`,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
console.error("Groq API error:", err);
|
|
return res.status(response.status).json({ error: err });
|
|
}
|
|
|
|
const data = await response.json();
|
|
// Return both text and word-level timestamps
|
|
res.json({
|
|
text: data.text,
|
|
words: data.words || [], // [{ word, start, end }]
|
|
segments: data.segments || [],
|
|
});
|
|
} catch (error) {
|
|
console.error("Transcription error:", error);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
// ═══ Stock Media Proxy (Pexels) ═══
|
|
const PEXELS_API_KEY = process.env.PEXELS_API_KEY || '';
|
|
|
|
app.get("/api/stock/photos", async (req, res) => {
|
|
if (!PEXELS_API_KEY) {
|
|
return res.status(501).json({ error: "PEXELS_API_KEY not configured" });
|
|
}
|
|
try {
|
|
const { q, page = "1", per_page = "20" } = req.query;
|
|
const endpoint = q
|
|
? `https://api.pexels.com/v1/search?query=${encodeURIComponent(String(q))}&page=${page}&per_page=${per_page}`
|
|
: `https://api.pexels.com/v1/curated?page=${page}&per_page=${per_page}`;
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: { Authorization: PEXELS_API_KEY },
|
|
});
|
|
const data = await response.json();
|
|
res.json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/stock/videos", async (req, res) => {
|
|
if (!PEXELS_API_KEY) {
|
|
return res.status(501).json({ error: "PEXELS_API_KEY not configured" });
|
|
}
|
|
try {
|
|
const { q, page = "1", per_page = "15" } = req.query;
|
|
const endpoint = q
|
|
? `https://api.pexels.com/videos/search?query=${encodeURIComponent(String(q))}&page=${page}&per_page=${per_page}`
|
|
: `https://api.pexels.com/videos/popular?page=${page}&per_page=${per_page}`;
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: { Authorization: PEXELS_API_KEY },
|
|
});
|
|
const data = await response.json();
|
|
res.json(data);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Download a stock file to uploads/ for persistent use
|
|
app.post("/api/stock/download", async (req, res) => {
|
|
try {
|
|
const { url, filename } = req.body;
|
|
if (!url || !filename) {
|
|
return res.status(400).json({ error: "url and filename required" });
|
|
}
|
|
|
|
const ext = path.extname(filename) || '.jpg';
|
|
const safeFilename = `stock-${crypto.randomUUID()}${ext}`;
|
|
const outputPath = path.join(UPLOADS_DIR, safeFilename);
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
fs.writeFileSync(outputPath, buffer);
|
|
|
|
res.json({ url: `/api/media/${safeFilename}` });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ═══ Render Queue ═══
|
|
const RENDERS_DIR = process.env.BRADLY_RENDERS_DIR || path.join(process.cwd(), "renders");
|
|
if (!fs.existsSync(RENDERS_DIR)) {
|
|
fs.mkdirSync(RENDERS_DIR, { recursive: true });
|
|
}
|
|
|
|
// Serve rendered files
|
|
app.use("/api/renders", express.static(RENDERS_DIR, {
|
|
maxAge: "1h",
|
|
}));
|
|
|
|
// Lazy-load render queue (heavy dependencies)
|
|
let renderQueue: typeof import("./src/server/renderQueue") | null = null;
|
|
async function getRenderQueue() {
|
|
if (!renderQueue) {
|
|
renderQueue = await import("./src/server/renderQueue");
|
|
}
|
|
return renderQueue;
|
|
}
|
|
|
|
// Start a render job
|
|
app.post("/api/render/start", async (req, res) => {
|
|
try {
|
|
const { format, width, height, fps, durationInFrames, compositionId, inputProps, targetPath, brandId } = req.body;
|
|
|
|
if (!format || !width || !height || !compositionId) {
|
|
return res.status(400).json({ error: "Missing required fields: format, width, height, compositionId" });
|
|
}
|
|
|
|
const rq = await getRenderQueue();
|
|
const job = rq.createJob({
|
|
format,
|
|
width,
|
|
height,
|
|
fps: fps || 30,
|
|
durationInFrames: durationInFrames || 150,
|
|
compositionId,
|
|
inputProps: inputProps || {},
|
|
targetPath,
|
|
brandId
|
|
});
|
|
|
|
console.log(`🎬 Job created: ${job.id} (${format} ${width}x${height})`);
|
|
res.json(job);
|
|
} catch (err: any) {
|
|
console.error("Render start error:", err);
|
|
res.status(500).json({ error: err.message || "Failed to create render job" });
|
|
}
|
|
});
|
|
|
|
// List all jobs
|
|
app.get("/api/render/jobs", async (_req, res) => {
|
|
try {
|
|
const rq = await getRenderQueue();
|
|
const jobs = rq.getAllJobs();
|
|
// Strip inputProps (too large for list)
|
|
const sanitized = jobs.map(({ inputProps, ...rest }) => rest);
|
|
res.json(sanitized);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get a single job by ID (used by batch exporter polling)
|
|
app.get("/api/render/jobs/:id", async (req, res) => {
|
|
try {
|
|
const rq = await getRenderQueue();
|
|
const job = rq.getJob(req.params.id);
|
|
if (!job) {
|
|
return res.status(404).json({ error: "Job not found" });
|
|
}
|
|
// Strip inputProps (too large)
|
|
const { inputProps, ...rest } = job;
|
|
res.json(rest);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete a job
|
|
app.delete("/api/render/jobs/:id", async (req, res) => {
|
|
try {
|
|
const rq = await getRenderQueue();
|
|
const deleted = rq.deleteJob(req.params.id);
|
|
res.json({ deleted });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// SSE — Real-time job progress
|
|
app.get("/api/render/events", async (req, res) => {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
|
|
// Send initial heartbeat
|
|
res.write("data: {\"type\":\"connected\"}\n\n");
|
|
|
|
const clientId = crypto.randomUUID();
|
|
const rq = await getRenderQueue();
|
|
|
|
const cleanup = rq.addSSEClient(clientId, (data: string) => {
|
|
res.write(`data: ${data}\n\n`);
|
|
});
|
|
|
|
// Heartbeat every 30s to keep connection alive
|
|
const heartbeat = setInterval(() => {
|
|
res.write(": heartbeat\n\n");
|
|
}, 30000);
|
|
|
|
req.on("close", () => {
|
|
clearInterval(heartbeat);
|
|
cleanup();
|
|
});
|
|
});
|
|
|
|
if (!process.env.BRADLY_ELECTRON) {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
const vite = await createViteServer({
|
|
server: { middlewareMode: true },
|
|
appType: "spa",
|
|
});
|
|
app.use(vite.middlewares);
|
|
} else {
|
|
const distPath = path.join(process.cwd(), "dist");
|
|
app.use(express.static(distPath));
|
|
app.get("*", (req, res) => {
|
|
res.sendFile(path.join(distPath, "index.html"));
|
|
});
|
|
}
|
|
}
|
|
|
|
return app;
|
|
}
|
|
|
|
// Direct execution (web mode only)
|
|
if (!process.env.BRADLY_ELECTRON) {
|
|
startServer();
|
|
}
|