[object Object]

← back to Marketing Command Center

Composer: one create flow — photo → details → song → decide reel-or-what

3c85e999d5c40b08652e93ca35be32865384796c · 2026-08-25 11:08:51 -0700 · Steve

Turn the Composer into the single 'pick a photo, add details, add a song, decide
the format' flow Steve wants, instead of hopping between Assets / Sounds / Reels /
Social panels.
- Format selector (📱 Feed post / 🎬 Reel / 📖 Story / 🎠 Carousel) in the assembly column
- Song picker sourced from the Sounds library (/api/sounds/catalog, 30 tracks) —
  search + in-place preview + selected-track chip with remove
- Smart coupling: adding a song auto-switches format→Reel (a photo can't carry audio
  as a feed post); picking a reel VIDEO also sets format→Reel
- Contextual hint: a still photo + song = a video → assemble here, then 'build in
  Reel Studio' (#reels) which renders the photo over the track
- format + song carried through /api/channels/publish and persisted on the outbox
  entry (additive, like handles) so the recipe survives for review/render

Verified: 4 format pills, 30-track picker, song→auto-Reel, chip renders; backend
round-trip persists format=reel + song on the outbox; no page errors.

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

Files touched

Diff

commit 3c85e999d5c40b08652e93ca35be32865384796c
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 11:08:51 2026 -0700

    Composer: one create flow — photo → details → song → decide reel-or-what
    
    Turn the Composer into the single 'pick a photo, add details, add a song, decide
    the format' flow Steve wants, instead of hopping between Assets / Sounds / Reels /
    Social panels.
    - Format selector (📱 Feed post / 🎬 Reel / 📖 Story / 🎠 Carousel) in the assembly column
    - Song picker sourced from the Sounds library (/api/sounds/catalog, 30 tracks) —
      search + in-place preview + selected-track chip with remove
    - Smart coupling: adding a song auto-switches format→Reel (a photo can't carry audio
      as a feed post); picking a reel VIDEO also sets format→Reel
    - Contextual hint: a still photo + song = a video → assemble here, then 'build in
      Reel Studio' (#reels) which renders the photo over the track
    - format + song carried through /api/channels/publish and persisted on the outbox
      entry (additive, like handles) so the recipe survives for review/render
    
    Verified: 4 format pills, 30-track picker, song→auto-Reel, chip renders; backend
    round-trip persists format=reel + song on the outbox; no page errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 modules/channels/index.js   | 10 ++++--
 public/panels/composer.html | 48 +++++++++++++++++++++++++
 public/panels/composer.js   | 86 ++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 141 insertions(+), 3 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 9e4a356..8b45fe0 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -1016,6 +1016,12 @@ module.exports = {
         // DW-owned IG accounts (@handles) this post targets — recorded on the outbox
         // entry so the selection is durable + the Board can filter a stream by account.
         const handles = Array.isArray(d.handles) ? d.handles.map(s => String(s).replace(/^@/, '')).filter(Boolean).slice(0, 50) : [];
+        // Assembled format decision (post/reel/story/carousel) + attached song, recorded
+        // on the outbox entry so the recipe survives for review / Reel-Studio render.
+        const FORMATS = ['post', 'reel', 'story', 'carousel'];
+        const format = FORMATS.includes(String(d.format)) ? String(d.format) : 'post';
+        const song = (d.song && d.song.url && /^https?:\/\//i.test(d.song.url))
+          ? { name: String(d.song.name || 'Track').slice(0, 200), url: String(d.song.url).slice(0, 800) } : undefined;
         const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '', videoUrl: d.videoUrl || '' };
         if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
         if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
@@ -1049,12 +1055,12 @@ module.exports = {
           if (live && !block) {
             const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
             const ok = r.length > 0 && r.every(x => x.ok);
-            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, handles: handles.length ? handles : undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
+            outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, handles: handles.length ? handles : undefined, format, song, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
             results.push({ channel: ch, live: true, ok, detail: r });
           } else {
             const reason = block ? `skipped — ${block}`
               : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
-            outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, handles: handles.length ? handles : undefined, status: reason, at: new Date().toISOString() });
+            outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, handles: handles.length ? handles : undefined, format, song, status: reason, at: new Date().toISOString() });
             results.push({ channel: ch, live: false, skipped: !!block, staged: true, reason });
           }
         }
diff --git a/public/panels/composer.html b/public/panels/composer.html
index be994f4..22f1734 100644
--- a/public/panels/composer.html
+++ b/public/panels/composer.html
@@ -38,6 +38,32 @@
       </div>
       <textarea id="cmp-caption" class="cmp-caption" placeholder="Write your caption — or hit ✨ Generate copy. #designerwallcoverings"></textarea>
     </div>
+
+    <!-- Decide the format + add a song: photo → details → song → reel-or-what, all here -->
+    <div class="cmp-make">
+      <div class="cmp-make-row">
+        <span class="cmp-make-lbl">Format</span>
+        <div class="cmp-fmts" id="cmp-fmts">
+          <button type="button" class="cmp-fmt on" data-fmt="post">📱 Feed post</button>
+          <button type="button" class="cmp-fmt" data-fmt="reel">🎬 Reel</button>
+          <button type="button" class="cmp-fmt" data-fmt="story">📖 Story</button>
+          <button type="button" class="cmp-fmt" data-fmt="carousel">🎠 Carousel</button>
+        </div>
+      </div>
+      <div class="cmp-make-row">
+        <span class="cmp-make-lbl">Song</span>
+        <div style="flex:1;min-width:0">
+          <div id="cmp-song-sel" class="cmp-song-sel" style="display:none"></div>
+          <button type="button" class="btn ghost" id="cmp-song-open" style="font-size:12px;padding:5px 10px">🎵 Add a song</button>
+          <div id="cmp-song-pick" class="cmp-song-pick" style="display:none">
+            <input id="cmp-song-search" class="cmp-search" placeholder="Search music…" style="margin-bottom:6px">
+            <div id="cmp-song-list" class="cmp-song-list"><div class="loading">Loading…</div></div>
+            <audio id="cmp-song-audio" style="width:100%;margin-top:6px;display:none" controls preload="none"></audio>
+          </div>
+        </div>
+      </div>
+      <div id="cmp-make-hint" class="cmp-make-hint muted"></div>
+    </div>
   </section>
 
   <!-- RIGHT: publish targets + fire -->
@@ -137,6 +163,28 @@
   .cmp-copybar{display:flex;align-items:center;gap:10px;margin-bottom:7px}
   .cmp-caption{width:100%;min-height:120px;font-size:13.5px;line-height:1.5;padding:10px;resize:vertical;
     border:1px solid var(--line,#e7e1d6);border-radius:10px;font-family:inherit}
+  /* Format + song block — the "decide if reel or what" step */
+  .cmp-make{border:1px solid var(--line,#ece6da);border-radius:12px;padding:11px;background:#fbfaf6;display:flex;flex-direction:column;gap:9px}
+  .cmp-make-row{display:flex;align-items:flex-start;gap:10px}
+  .cmp-make-lbl{font-size:11.5px;font-weight:700;color:var(--mut,#8a8170);min-width:52px;padding-top:6px}
+  .cmp-fmts{display:flex;gap:6px;flex-wrap:wrap;flex:1}
+  .cmp-fmt{font-size:12px;padding:6px 11px;border:1px solid var(--line,#e7e1d6);background:#fff;border-radius:999px;
+    cursor:pointer;color:var(--mut,#8a8170);font-weight:600;white-space:nowrap}
+  .cmp-fmt:hover{background:#f3eee3}
+  .cmp-fmt.on{background:var(--ink,#2a2620);color:#fff;border-color:var(--ink,#2a2620)}
+  .cmp-song-sel{display:flex;align-items:center;gap:8px;font-size:12.5px;padding:6px 9px;border:1px solid #d8cfb8;
+    border-radius:9px;background:#fff8ec;margin-bottom:4px}
+  .cmp-song-sel .nm{flex:1;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .cmp-song-sel .x{cursor:pointer;color:#9a5a5a;font-weight:700;border:0;background:transparent;font-size:14px;padding:0 2px}
+  .cmp-song-pick{margin-top:6px;border:1px solid var(--line,#e7e1d6);border-radius:9px;padding:7px;background:#fff}
+  .cmp-song-list{max-height:150px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
+  .cmp-song-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:5px 7px;border-radius:7px;cursor:pointer}
+  .cmp-song-row:hover{background:#f4efe3}
+  .cmp-song-row .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .cmp-song-row .pv{font-size:13px;color:var(--mut,#8a8170);border:0;background:transparent;cursor:pointer}
+  .cmp-song-row .sec{font-size:9.5px;color:#a49a86}
+  .cmp-make-hint{font-size:11px;line-height:1.4;min-height:2px}
+  .cmp-make-hint a{color:var(--gold,#8a6d2f);font-weight:600}
   .cmp-rhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
   .cmp-targets{display:flex;flex-direction:column;gap:7px;overflow-y:auto;flex:1;margin-bottom:10px}
   .cmp-tgt{display:flex;align-items:center;gap:9px;font-size:13px;padding:8px 10px;border:1px solid var(--line,#ece6da);
diff --git a/public/panels/composer.js b/public/panels/composer.js
index 5763a7d..9f51b82 100644
--- a/public/panels/composer.js
+++ b/public/panels/composer.js
@@ -7,7 +7,7 @@ window.MCC_PANELS['composer'] = {
     const jget = async u => { try { const r = await fetch(O + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
     const jpost = async (u, b) => { try { const r = await fetch(O + u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); return await r.json().catch(() => ({})); } catch (e) { return { error: e.message }; } };
 
-    const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set() };
+    const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set(), format: 'post', song: null };
     const IG_LS_KEY = 'mccComposerIgHandles'; // persist the account selection across reloads
     const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
 
@@ -181,6 +181,7 @@ window.MCC_PANELS['composer'] = {
       const v = $('#cmp-video'); if (v) { v.style.display = 'none'; v.removeAttribute('src'); }
       const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none';   // asset chosen → hide any banner preview
       $('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
+      if (typeof updateMakeHint === 'function') updateMakeHint();       // photo chosen → refresh reel-or-what hint
     }
     // Pick a reel → the post carries its public video URL (videoUrl), not a still.
     function selectReel(el, r) {
@@ -193,6 +194,8 @@ window.MCC_PANELS['composer'] = {
       $('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
       // seed the reel's own caption when the box is empty (keeps media + copy aligned)
       if (r.caption && !$('#cmp-caption').value.trim()) { $('#cmp-caption').value = r.caption; countIt(); }
+      if (state.format === 'post') setFormat('reel');   // a reel video ⇒ format is Reel
+      else updateMakeHint();
     }
     // Render a Layouts template LIVE into the preview via /api/layouts/render (returns HTML).
     async function renderBanner(templateId, el, aspect) {
@@ -240,6 +243,81 @@ window.MCC_PANELS['composer'] = {
       $('#cmp-gen').disabled = false; $('#cmp-gen').textContent = '✨ Generate copy';
     });
 
+    // ── FORMAT + SONG: "decide if reel or what", and attach a track ────────────
+    // Photo → details (above) → song → format, all assembled here before publish.
+    function setFormat(fmt) {
+      state.format = fmt;
+      root.querySelectorAll('.cmp-fmt').forEach(b => b.classList.toggle('on', b.dataset.fmt === fmt));
+      updateMakeHint();
+    }
+    root.querySelectorAll('.cmp-fmt').forEach(b => b.addEventListener('click', () => setFormat(b.dataset.fmt)));
+
+    function updateMakeHint() {
+      const h = $('#cmp-make-hint'); if (!h) return;
+      // 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).`;
+      } 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) {
+        h.textContent = 'Tip: add a song for the reel, or pick a ready-made reel video on the left.';
+      } else { h.textContent = ''; }
+    }
+
+    function renderSong() {
+      const sel = $('#cmp-song-sel'), open = $('#cmp-song-open');
+      if (state.song) {
+        sel.style.display = 'flex';
+        sel.innerHTML = `<span>🎵</span><span class="nm" title="${esc(state.song.name)}">${esc(state.song.name)}</span>` +
+          `<button type="button" class="pv" title="Preview">▶</button><button type="button" class="x" title="Remove song">×</button>`;
+        sel.querySelector('.x').onclick = () => { state.song = null; renderSong(); updateMakeHint(); };
+        sel.querySelector('.pv').onclick = () => { const a = $('#cmp-song-audio'); a.src = state.song.url; a.style.display = 'block'; a.play().catch(() => {}); };
+        open.textContent = '🎵 Change song';
+      } else {
+        sel.style.display = 'none'; sel.innerHTML = '';
+        open.textContent = '🎵 Add a song';
+      }
+    }
+
+    let SONGS = null;
+    async function loadSongs() {
+      const list = $('#cmp-song-list'); if (!list) return;
+      if (!SONGS) {
+        const d = await jget('/api/sounds/catalog');
+        // flatten grouped sections → {name, url, sec}; keep only playable rows (a url)
+        const secs = (d && (d.sections || d.catalog || [])) || [];
+        SONGS = [];
+        secs.forEach(s => (s.items || []).forEach(it => { if (it && it.url) SONGS.push({ name: it.name || it.title || 'Track', url: it.url, sec: s.title || '' }); }));
+      }
+      if (!SONGS.length) { list.innerHTML = '<div class="bd-empty" style="font-size:11.5px">No tracks in the Sounds library yet.</div>'; return; }
+      list.innerHTML = '';
+      SONGS.forEach(t => {
+        const row = document.createElement('div'); row.className = 'cmp-song-row';
+        row.innerHTML = `<button type="button" class="pv" title="Preview">▶</button><span class="nm" title="${esc(t.name)}">${esc(t.name)}</span><span class="sec">${esc(t.sec)}</span>`;
+        row.querySelector('.pv').onclick = (e) => { e.stopPropagation(); const a = $('#cmp-song-audio'); a.src = t.url; a.style.display = 'block'; a.play().catch(() => {}); };
+        row.onclick = () => {                                   // pick this song
+          state.song = { name: t.name, url: t.url };
+          if (state.format === 'post') setFormat('reel');       // a song ⇒ this is a reel, not a feed post
+          $('#cmp-song-pick').style.display = 'none';
+          renderSong(); updateMakeHint();
+        };
+        list.appendChild(row);
+      });
+    }
+    $('#cmp-song-open').addEventListener('click', () => {
+      const pick = $('#cmp-song-pick');
+      const showing = pick.style.display !== 'none';
+      pick.style.display = showing ? 'none' : 'block';
+      if (!showing) loadSongs();
+    });
+    $('#cmp-song-search').addEventListener('input', e => {
+      const q = e.target.value.toLowerCase();
+      root.querySelectorAll('#cmp-song-list .cmp-song-row').forEach(r => {
+        r.style.display = ((r.querySelector('.nm') || {}).textContent || '').toLowerCase().includes(q) ? '' : 'none';
+      });
+    });
+
     // ── PUBLISH ────────────────────────────────────────────────────────────────
     $('#cmp-publish').addEventListener('click', async () => {
       const out = $('#cmp-result'); out.innerHTML = '';
@@ -259,6 +337,11 @@ window.MCC_PANELS['composer'] = {
         // the existing gated publish/stage flow is unchanged; this just carries the
         // user's per-account selection through to the server.
         handles: [...state.handles],
+        // The assembled format decision + attached song (name+url). Additive metadata,
+        // recorded on the staged/outbox entry so the recipe (photo + song + format) is
+        // captured for review / Reel-Studio render.
+        format: state.format,
+        song: state.song || undefined,
         confirm: !dry, dryRun: dry,
       });
       if (r && r.results) {
@@ -284,6 +367,7 @@ window.MCC_PANELS['composer'] = {
 
     await Promise.all([loadTargets(), loadSource('assets'), loadIgAccounts()]);
     countIt();
+    renderSong(); updateMakeHint();
 
     // Quick Post — easy per-platform "stage a draft" buttons (gated). Reads the
     // live caption + whichever media (reel video or image) is selected.

← 4a911a5 assets: background catalog warmer (boot + 6h) + manual refre  ·  back to Marketing Command Center  ·  Add /posts/refresh-one endpoint: fresh IG image on 'Make it 796eba1 →