Files

392 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";
import cors from "cors";
// ═══ 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 CORS
app.use(cors());
// 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, subfolder = "brand" } = req.body;
if (!brandId || !workspacePath) {
return res.status(400).json({ error: "brandId and workspacePath required" });
}
const brandDir = path.join(workspacePath, brandId, subfolder);
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}/${subfolder}/${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();
}