From 0f49218f62cef7e4a92f747e82d2ce2303f505bb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 25 Mar 2026 11:26:04 +0100 Subject: [PATCH] fix: embed local images as base64 data URLs for Remotion renderer Remotion's headless Chromium cannot reliably fetch http://localhost inside Docker due to network sandboxing. Instead of HTTP URLs, convert all local /uploads/... files to data:image/...;base64,... before passing to inputProps. This is reliable in all environments (Docker, dev, CI). Audio files remain as HTTP URLs since they are loaded via Web Audio API which has different network handling. Co-Authored-By: Claude Sonnet 4.6 --- server/routes/remotion.ts | 46 ++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts index 59482c8..6c6a330 100644 --- a/server/routes/remotion.ts +++ b/server/routes/remotion.ts @@ -87,24 +87,54 @@ const ENDING_SECONDS = 3; // ending slide (logo + store name) const MIN_CONTENT_SECONDS = 22; // so total >= 25s const MAX_CONTENT_SECONDS = 27; // so total <= 30s +/** + * Converts a local /uploads/... relative path to a base64 data URL. + * Chromium inside Docker cannot reliably fetch http://localhost via HTTP, + * so we embed images directly — zero network dependency. + */ +async function toDataUrl(relativeUrl: string): Promise { + if (!relativeUrl || !relativeUrl.startsWith('/uploads/')) return relativeUrl; + const filePath = path.join(process.cwd(), relativeUrl); + try { + const buffer = await fs.promises.readFile(filePath); + const ext = path.extname(filePath).slice(1).toLowerCase(); + const mime = ext === 'png' ? 'image/png' + : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' + : 'image/jpeg'; + return `data:${mime};base64,${buffer.toString('base64')}`; + } catch { + console.warn(`⚠️ toDataUrl: file not found: ${filePath}`); + return relativeUrl; + } +} + remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => { try { const fields = req.files as Record; const files = fields["images"] ?? []; let imageUrls: string[] = []; - // Remotion's Chromium runs inside the same container — must use localhost, not the Docker service hostname + // For audio/music we still need HTTP URLs (data URLs for audio are too large) const port = process.env.PORT || "5555"; const host = `http://localhost:${port}`; + // Library images: resolve relative paths → data URLs (avoids Chromium HTTP issues) const existing = req.body.existingImageUrls; if (existing) { - const toAbsolute = (url: string) => url.startsWith('/') ? `${host}${url}` : url; - if (Array.isArray(existing)) imageUrls.push(...existing.map(toAbsolute)); - else imageUrls.push(toAbsolute(existing)); + const rawUrls = Array.isArray(existing) ? existing : [existing]; + const resolved = await Promise.all(rawUrls.map(toDataUrl)); + imageUrls.push(...resolved); } + // Newly uploaded temp files: read from disk → data URL if (files && files.length > 0) { - imageUrls.push(...files.map(f => `${host}/uploads/temp/${path.basename(f.path)}`)); + const resolved = await Promise.all(files.map(async f => { + const buffer = await fs.promises.readFile(f.path); + const ext = path.extname(f.originalname).slice(1).toLowerCase(); + const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; + return `data:${mime};base64,${buffer.toString('base64')}`; + })); + imageUrls.push(...resolved); } if (imageUrls.length === 0) { @@ -118,7 +148,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { const ttsVoice: string = req.body.ttsVoice || "fr-FR-VivienneMultilingualNeural"; const musicVolume: number = parseFloat(req.body.musicVolume ?? "0.3"); - // Music: uploaded file takes priority, else use catalog track URL (make relative paths absolute) + // Music: HTTP URLs are fine for audio (Remotion uses Web Audio API, not ) const musicFile = fields["music"]?.[0]; const rawMusicTrackUrl = req.body.musicTrackUrl as string | undefined; const musicUrl: string | undefined = musicFile @@ -132,8 +162,8 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { const cloudinaryConfig = await dbStorage.getCloudinaryConfig(); if (cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId) { const relLogo = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId, cloudinaryConfig.publicUrl); - logoUrl = relLogo.startsWith('/') ? `${host}${relLogo}` : relLogo; - console.log("🏢 Logo URL:", logoUrl); + logoUrl = await toDataUrl(relLogo); + console.log("🏢 Logo embedded as data URL:", logoUrl.slice(0, 40) + "..."); } } catch (e) { console.warn("⚠️ Could not fetch logo config:", e);