From 57ad5c055afdd33081119d979e8b054b817a842f Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 11 Feb 2026 08:40:47 -0800 Subject: [PATCH] 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) --- server.js | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 7 deletions(-) diff --git a/server.js b/server.js index e8da91d..5f59f42 100644 --- a/server.js +++ b/server.js @@ -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}`); } }