[object Object]

← back to Marketing Command Center

Composer: 'Build this reel' handoff to Reel Studio + fix reels-proxy POST body

7186df003b9fd1752035dfeba30c7e3cdd2ce8c1 · 2026-08-26 08:34:50 -0700 · Steve

Finish the create flow's last link — a composed photo + song + caption now actually
RENDERS to a reel. The composer's photo+song+reel hint became a 'Build this reel ->' button
that POSTs the recipe to /api/reels/ui/api/build-photo-reel (proxied to the reels app) and
opens Reel Studio to watch the render.

Fix (found in verification): the reels reverse-proxy did req.pipe(up), but express.json had
ALREADY consumed the POST body upstream — so every proxied POST forwarded an EMPTY body and
the reel endpoint 400'd on a valid request. Now re-serializes the parsed body + sets
Content-Length; GET/video streams still pipe raw.

Verified: proxied POST → 202 (body arrives); the button appears on photo+song+Reel; a real
mp4 with the song comes out the far end. Note: render only runs on the studio host
(ALLOW_BUILD=1); prod serves reels, so it reports that honestly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7186df003b9fd1752035dfeba30c7e3cdd2ce8c1
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 26 08:34:50 2026 -0700

    Composer: 'Build this reel' handoff to Reel Studio + fix reels-proxy POST body
    
    Finish the create flow's last link — a composed photo + song + caption now actually
    RENDERS to a reel. The composer's photo+song+reel hint became a 'Build this reel ->' button
    that POSTs the recipe to /api/reels/ui/api/build-photo-reel (proxied to the reels app) and
    opens Reel Studio to watch the render.
    
    Fix (found in verification): the reels reverse-proxy did req.pipe(up), but express.json had
    ALREADY consumed the POST body upstream — so every proxied POST forwarded an EMPTY body and
    the reel endpoint 400'd on a valid request. Now re-serializes the parsed body + sets
    Content-Length; GET/video streams still pipe raw.
    
    Verified: proxied POST → 202 (body arrives); the button appears on photo+song+Reel; a real
    mp4 with the song comes out the far end. Note: render only runs on the studio host
    (ALLOW_BUILD=1); prod serves reels, so it reports that honestly.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 modules/reels/index.js    | 14 +++++++++++++-
 public/panels/composer.js | 26 +++++++++++++++++++++++++-
 2 files changed, 38 insertions(+), 2 deletions(-)

diff --git a/modules/reels/index.js b/modules/reels/index.js
index b0cbade..8f4f276 100644
--- a/modules/reels/index.js
+++ b/modules/reels/index.js
@@ -27,6 +27,18 @@ function upstream(pathname, req, res) {
   if (req.headers.range) headers.Range = req.headers.range;
   if (req.headers['content-type']) headers['Content-Type'] = req.headers['content-type'];
 
+  // Express's json body-parser has ALREADY consumed the request stream on POSTs, so
+  // req.pipe(up) would forward an EMPTY body (the upstream endpoint then sees no data).
+  // If a parsed body is present, re-serialize it and send it explicitly; otherwise
+  // pipe the raw stream (GET / video / anything without a parsed body).
+  const parsed = req.body && typeof req.body === 'object' && Object.keys(req.body).length ? req.body : null;
+  let payload = null;
+  if (parsed) {
+    payload = JSON.stringify(req.body);
+    headers['Content-Type'] = 'application/json';
+    headers['Content-Length'] = Buffer.byteLength(payload);
+  }
+
   const up = lib.request({
     method: req.method,
     hostname: u.hostname,
@@ -45,7 +57,7 @@ function upstream(pathname, req, res) {
     if (!res.headersSent) res.status(502).json({ error: `reels app unreachable: ${e.message}`, base: BASE });
     else res.end();
   });
-  req.pipe(up);
+  if (payload !== null) up.end(payload); else req.pipe(up);
 }
 
 module.exports = {
diff --git a/public/panels/composer.js b/public/panels/composer.js
index 9f51b82..bfbcd35 100644
--- a/public/panels/composer.js
+++ b/public/panels/composer.js
@@ -257,7 +257,9 @@ window.MCC_PANELS['composer'] = {
       // A still photo + a song = a reel that has to be RENDERED (photo over music →
       // mp4). The composer assembles the recipe; Reel Studio produces the video.
       if (state.song && (state.format === 'reel' || state.format === 'story') && state.mediaUrl && !state.videoUrl) {
-        h.innerHTML = `🎬 Photo + song → this is a video. Assemble it here, then <a href="#reels">build it in Reel Studio</a> (renders the photo over the track).`;
+        h.innerHTML = `🎬 Photo + song → this renders to a video. ` +
+          `<button type="button" id="cmp-buildreel" class="btn gold" style="font-size:11.5px;padding:4px 11px;margin-left:2px">Build this reel →</button> ` +
+          `<span class="muted" id="cmp-buildreel-msg" style="font-size:11px"></span>`;
       } else if (state.song && state.format === 'post') {
         h.innerHTML = `🎵 A feed photo can’t carry audio — a song makes it a <b>Reel</b> or <b>Story</b>. Switched the format for you.`;
       } else if (state.format === 'reel' && !state.song && !state.videoUrl) {
@@ -318,6 +320,28 @@ window.MCC_PANELS['composer'] = {
       });
     });
 
+    // "Build this reel →" — hand the photo + song + caption to Reel Studio, which
+    // renders the still over the track to an mp4 (POST proxied to the reels app;
+    // the render only runs on the studio host, so on prod it reports that honestly).
+    // Delegated on #cmp-make-hint because updateMakeHint() rebuilds its innerHTML.
+    $('#cmp-make-hint').addEventListener('click', async (e) => {
+      const btn = e.target.closest('#cmp-buildreel'); if (!btn) return;
+      if (!state.mediaUrl || !state.song) return;
+      const msg = $('#cmp-buildreel-msg');
+      btn.disabled = true; btn.textContent = 'Building…';
+      const r = await jpost('/api/reels/ui/api/build-photo-reel', {
+        photo: state.mediaUrl, song: state.song.url, songName: state.song.name,
+        caption: cap.value.trim(), seconds: 15,
+      });
+      if (r && r.started) {
+        if (msg) msg.textContent = '✓ rendering in Reel Studio — opening…';
+        setTimeout(() => { location.hash = 'reels'; }, 800);
+      } else {
+        btn.disabled = false; btn.textContent = 'Build this reel →';
+        if (msg) msg.textContent = '⚠ ' + ((r && r.error) || 'could not start — reel rendering runs on the studio host');
+      }
+    });
+
     // ── PUBLISH ────────────────────────────────────────────────────────────────
     $('#cmp-publish').addEventListener('click', async () => {
       const out = $('#cmp-result'); out.innerHTML = '';

← fb74fe6 auto-data-snapshot: 2026-08-25T20:06:24 (1 data files) — pub  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-08-26T09:01:00 (1 data files) — pub 6bb98c4 →