video understanding
This commit is contained in:
184
server.js
184
server.js
@@ -2,21 +2,40 @@ import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { execFile } from 'child_process';
|
||||
import util from 'util';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { GoogleAIFileManager } from '@google/generative-ai/server';
|
||||
|
||||
const execFilePromise = util.promisify(execFile);
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const RECIPES_DIR = path.join(__dirname, 'recipes');
|
||||
const TEMP_DIR = path.join(__dirname, 'temp_ingest');
|
||||
|
||||
// Ensure directories exist
|
||||
async function ensureDirs() {
|
||||
try { await fs.access(RECIPES_DIR); } catch { await fs.mkdir(RECIPES_DIR); }
|
||||
try { await fs.access(TEMP_DIR); } catch { await fs.mkdir(TEMP_DIR); }
|
||||
}
|
||||
ensureDirs();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Serve static frontend files from Vite build
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
// Secure credentials from environment variables, or fallback to defaults
|
||||
const VALID_USER = process.env.APP_USERNAME || 'admin';
|
||||
const VALID_PASS = process.env.APP_PASSWORD || 'souschef';
|
||||
// Secure credentials strictly from environment variables
|
||||
const VALID_USER = process.env.APP_USERNAME;
|
||||
const VALID_PASS = process.env.APP_PASSWORD;
|
||||
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY;
|
||||
|
||||
app.post('/api/login', (req, res) => {
|
||||
@@ -62,12 +81,169 @@ app.post('/api/generate', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/ingest', async (req, res) => {
|
||||
const { username, password, url } = req.body;
|
||||
|
||||
if (username !== VALID_USER || password !== VALID_PASS) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
return res.status(500).json({ error: 'API key not configured on server' });
|
||||
}
|
||||
|
||||
if (!url) return res.status(400).json({ error: 'Reel URL is required' });
|
||||
|
||||
const jobId = crypto.randomBytes(8).toString('hex');
|
||||
const jobDir = path.join(TEMP_DIR, jobId);
|
||||
|
||||
try {
|
||||
await fs.mkdir(jobDir, { recursive: true });
|
||||
|
||||
console.log(`[Ingest ${jobId}] Step 1: Running yt-dlp on ${url}`);
|
||||
const videoOutputTemplate = path.join(jobDir, 'video.%(ext)s');
|
||||
const descFile = path.join(jobDir, 'video.description');
|
||||
|
||||
await execFilePromise('yt-dlp', [
|
||||
'-f', 'best[ext=mp4]/best',
|
||||
'--no-playlist',
|
||||
'-o', videoOutputTemplate,
|
||||
'--write-description',
|
||||
url
|
||||
]);
|
||||
|
||||
const dirFiles = await fs.readdir(jobDir);
|
||||
const videoFile = dirFiles.find(f => /^video\.(mp4|webm|mkv|mov|avi)$/i.test(f));
|
||||
if (!videoFile) throw new Error('yt-dlp did not produce a video file');
|
||||
const videoPath = path.join(jobDir, videoFile);
|
||||
const ext = path.extname(videoFile).slice(1).toLowerCase();
|
||||
const MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska', mov: 'video/quicktime', avi: 'video/x-msvideo' };
|
||||
const videoMimeType = MIME_MAP[ext] || 'video/mp4';
|
||||
|
||||
let captionText = '';
|
||||
if (existsSync(descFile)) captionText = await fs.readFile(descFile, 'utf-8');
|
||||
|
||||
console.log(`[Ingest ${jobId}] Step 2: Uploading file to Gemini (${videoFile})...`);
|
||||
const fileManager = new GoogleAIFileManager(GEMINI_API_KEY);
|
||||
const uploadResult = await fileManager.uploadFile(videoPath, {
|
||||
mimeType: videoMimeType,
|
||||
displayName: `Reel Ingest ${jobId}`,
|
||||
});
|
||||
|
||||
let file = await fileManager.getFile(uploadResult.file.name);
|
||||
console.log(`[Ingest ${jobId}] Uploaded ${file.displayName} as: ${file.uri}`);
|
||||
|
||||
// Wait for the video processing to complete on Google's side
|
||||
while (file.state === "PROCESSING") {
|
||||
process.stdout.write(".");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
file = await fileManager.getFile(uploadResult.file.name);
|
||||
}
|
||||
|
||||
if (file.state === "FAILED") {
|
||||
throw new Error("Video processing failed inside Gemini.");
|
||||
}
|
||||
|
||||
console.log(`\n[Ingest ${jobId}] Step 3: Sending prompt to Gemini...`);
|
||||
|
||||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
|
||||
|
||||
const systemPrompt = "You are a professional chef. You consume video, audio, and captions from a recipe reel, and output structured JSON. Required keys: title (string), description (string), servings (number), prepTime (string), cookTime (string), ingredients (array of {amount, unit, name} objects), steps (array of {title, instruction} objects), and notes (string).";
|
||||
|
||||
const result = await model.generateContent({
|
||||
contents: [
|
||||
{
|
||||
role: 'user', parts: [
|
||||
{ fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } },
|
||||
{ text: "Extract the recipe (ingredients, measurements, and steps) into the required JSON format." },
|
||||
...(captionText.trim() ? [{ text: `Original Social Media Caption:\n"${captionText}"\n` }] : [])
|
||||
]
|
||||
}
|
||||
],
|
||||
systemInstruction: { role: 'system', parts: [{ text: systemPrompt }] },
|
||||
generationConfig: { responseMimeType: "application/json", temperature: 0.2 }
|
||||
});
|
||||
|
||||
const recipeRaw = JSON.parse(result.response.text());
|
||||
|
||||
// Save securely
|
||||
const recipeId = crypto.randomBytes(8).toString('hex');
|
||||
await fs.writeFile(path.join(RECIPES_DIR, `${recipeId}.json`), JSON.stringify(recipeRaw, null, 2));
|
||||
|
||||
// Cleanup local temp dir and remote API file
|
||||
await fs.rm(jobDir, { recursive: true, force: true });
|
||||
await fileManager.deleteFile(uploadResult.file.name);
|
||||
|
||||
res.json({ id: recipeId, recipe: recipeRaw });
|
||||
} catch (err) {
|
||||
console.error(`[Ingest ${jobId}] Failed`, err);
|
||||
// Try to cleanup jobDir just in case
|
||||
try { await fs.rm(jobDir, { recursive: true, force: true }); } catch { }
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/recipes', async (req, res) => {
|
||||
const { username, password, recipe } = req.body;
|
||||
|
||||
if (username !== VALID_USER || password !== VALID_PASS) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
await fs.writeFile(path.join(RECIPES_DIR, `${id}.json`), JSON.stringify(recipe));
|
||||
res.json({ id });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to save recipe' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (req, res) => {
|
||||
// Pass auth via headers
|
||||
const username = req.headers['x-username'];
|
||||
const password = req.headers['x-password'];
|
||||
|
||||
if (username !== VALID_USER || password !== VALID_PASS) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(RECIPES_DIR);
|
||||
const recipes = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const content = await fs.readFile(path.join(RECIPES_DIR, file), 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
parsed.id = file.replace('.json', '');
|
||||
recipes.push(parsed);
|
||||
}
|
||||
}
|
||||
res.json(recipes);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch recipes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/recipes/:id', async (req, res) => {
|
||||
// Public route - anyone with the ID can view
|
||||
try {
|
||||
const recipeId = req.params.id.replace(/[^a-z0-9]/gi, ''); // basic sanitization
|
||||
const content = await fs.readFile(path.join(RECIPES_DIR, `${recipeId}.json`), 'utf-8');
|
||||
res.json(JSON.parse(content));
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: 'Recipe not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// For any other route, send the frontend
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Secure proxy server running on port ${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user