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:
2026-02-11 08:40:47 -08:00
parent 2e111d601c
commit 57ad5c055a

133
server.js
View File

@@ -5,11 +5,12 @@ import { WebSocketServer, WebSocket } from 'ws';
import jwt from 'jsonwebtoken';
import fetch from 'node-fetch';
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 { dirname, join, basename } from 'path';
import multer from 'multer';
import { spawn } from 'child_process';
import crypto from 'crypto';
// Get current directory for ES modules
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
// 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
if (parsed.type === 'event' && parsed.event === 'chat') {
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)
if (payloadSessionKey && payloadSessionKey !== 'agent:main:main' && payloadSessionKey !== expectedSessionKey) {
console.log(`[proxy] Filtering out chat event: sessionKey=${payloadSessionKey}, expected=${expectedSessionKey}`);
// Block desktop/main session messages from going to mobile
if (payloadSessionKey === 'agent:main:main') {
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;
} else {
console.log(`[proxy] Forwarding chat event: sessionKey=${payloadSessionKey || 'main'} for user ${clientUserId}`);
}
}