Fix GIF-to-MP4 conversion endpoint
- Add crypto import for ES module compatibility - Add unlinkSync to fs imports - Remove require() calls (ES module uses import) - Add PATH environment to systemd service for ffmpeg access - Successfully tested with Tenor GIF (556KB MP4 output)
This commit is contained in:
133
server.js
133
server.js
@@ -5,11 +5,12 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import admin from 'firebase-admin';
|
import admin from 'firebase-admin';
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join, basename } from 'path';
|
import { dirname, join, basename } from 'path';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
// Get current directory for ES modules
|
// Get current directory for ES modules
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -473,6 +474,114 @@ app.post('/api/tts', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GIF to MP4 conversion endpoint
|
||||||
|
app.get('/api/convert-gif', async (req, res) => {
|
||||||
|
const { url } = req.query;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'url parameter is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL is a GIF
|
||||||
|
if (!url.match(/\.(gif|GIF)(\?|$)/)) {
|
||||||
|
return res.status(400).json({ error: 'URL must point to a GIF file' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a hash of the URL for caching
|
||||||
|
const urlHash = crypto.createHash('md5').update(url).digest('hex');
|
||||||
|
const outputFile = join(uploadsDir, `gif-${urlHash}.mp4`);
|
||||||
|
|
||||||
|
// Check if already converted
|
||||||
|
if (existsSync(outputFile)) {
|
||||||
|
console.log(`[gif] Using cached conversion: ${basename(outputFile)}`);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
videoUrl: `/uploads/${basename(outputFile)}`,
|
||||||
|
filename: basename(outputFile),
|
||||||
|
cached: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[gif] Converting GIF to MP4: ${url}`);
|
||||||
|
|
||||||
|
// Download GIF to temp file
|
||||||
|
const tempGif = join(uploadsDir, `temp-${urlHash}.gif`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download GIF: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
writeFileSync(tempGif, Buffer.from(buffer));
|
||||||
|
|
||||||
|
console.log(`[gif] Downloaded ${buffer.byteLength} bytes, converting...`);
|
||||||
|
|
||||||
|
// Convert GIF to MP4 using ffmpeg
|
||||||
|
const ffmpegProcess = spawn('ffmpeg', [
|
||||||
|
'-i', tempGif,
|
||||||
|
'-movflags', 'faststart',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||||||
|
'-y', // Overwrite output
|
||||||
|
outputFile
|
||||||
|
], {
|
||||||
|
timeout: 60000 // 1 minute timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpegProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegProcess.on('close', (code) => {
|
||||||
|
// Clean up temp GIF
|
||||||
|
try {
|
||||||
|
if (existsSync(tempGif)) {
|
||||||
|
unlinkSync(tempGif);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[gif] Failed to delete temp file: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`[gif] ffmpeg failed with code ${code}: ${stderr}`);
|
||||||
|
return res.status(500).json({ error: `Conversion failed: ${stderr.substring(0, 200)}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file was created
|
||||||
|
if (!existsSync(outputFile)) {
|
||||||
|
console.error('[gif] ffmpeg succeeded but no video file created');
|
||||||
|
return res.status(500).json({ error: 'No video file generated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoUrl = `/uploads/${basename(outputFile)}`;
|
||||||
|
const fileSize = statSync(outputFile).size;
|
||||||
|
|
||||||
|
console.log(`[gif] Converted to MP4: ${basename(outputFile)} (${fileSize} bytes)`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
filename: basename(outputFile),
|
||||||
|
size: fileSize,
|
||||||
|
cached: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegProcess.on('error', (err) => {
|
||||||
|
console.error('[gif] ffmpeg spawn error:', err);
|
||||||
|
res.status(500).json({ error: `Failed to spawn ffmpeg: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[gif] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Streaming endpoint removed - SAG doesn't easily support stdout streaming
|
// Streaming endpoint removed - SAG doesn't easily support stdout streaming
|
||||||
// The file-based endpoint with extended timeout is sufficient for now
|
// The file-based endpoint with extended timeout is sufficient for now
|
||||||
|
|
||||||
@@ -787,14 +896,24 @@ wss.on('connection', async (clientWs, req) => {
|
|||||||
// Filter chat events by sessionKey - only forward if it's for this user's session
|
// Filter chat events by sessionKey - only forward if it's for this user's session
|
||||||
if (parsed.type === 'event' && parsed.event === 'chat') {
|
if (parsed.type === 'event' && parsed.event === 'chat') {
|
||||||
const payloadSessionKey = parsed.payload?.sessionKey;
|
const payloadSessionKey = parsed.payload?.sessionKey;
|
||||||
const expectedSessionKey = `agent:main:${clientUserId}`;
|
|
||||||
|
|
||||||
// Only forward if sessionKey matches user's session (or is main/unspecified for backwards compat)
|
// Block desktop/main session messages from going to mobile
|
||||||
if (payloadSessionKey && payloadSessionKey !== 'agent:main:main' && payloadSessionKey !== expectedSessionKey) {
|
if (payloadSessionKey === 'agent:main:main') {
|
||||||
console.log(`[proxy] Filtering out chat event: sessionKey=${payloadSessionKey}, expected=${expectedSessionKey}`);
|
console.log(`[proxy] Filtering out desktop session message for mobile user ${clientUserId}`);
|
||||||
|
shouldForward = false;
|
||||||
|
}
|
||||||
|
// Forward if sessionKey matches this user (e.g., "shadow")
|
||||||
|
else if (payloadSessionKey === clientUserId || payloadSessionKey === `agent:main:${clientUserId}`) {
|
||||||
|
console.log(`[proxy] Forwarding user session message: sessionKey=${payloadSessionKey} for user ${clientUserId}`);
|
||||||
|
}
|
||||||
|
// Forward if no sessionKey (backwards compat - unlikely to happen)
|
||||||
|
else if (!payloadSessionKey) {
|
||||||
|
console.log(`[proxy] Forwarding message with no sessionKey for user ${clientUserId}`);
|
||||||
|
}
|
||||||
|
// Block all other sessions
|
||||||
|
else {
|
||||||
|
console.log(`[proxy] Filtering out message from different session: sessionKey=${payloadSessionKey}, user=${clientUserId}`);
|
||||||
shouldForward = false;
|
shouldForward = false;
|
||||||
} else {
|
|
||||||
console.log(`[proxy] Forwarding chat event: sessionKey=${payloadSessionKey || 'main'} for user ${clientUserId}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user