[object Object]

← back to Dw Photo Capture

Video transcode poll: /api/media-status reads Shopify video processing state; client polls after upload until every clip is READY (or fails), updating the note in-place

aa3a6cebdd423e8ba6efa90daf51de72e9945f2c · 2026-07-06 20:38:09 -0700 · Steve Abrams

Files touched

Diff

commit aa3a6cebdd423e8ba6efa90daf51de72e9945f2c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 20:38:09 2026 -0700

    Video transcode poll: /api/media-status reads Shopify video processing state; client polls after upload until every clip is READY (or fails), updating the note in-place
---
 public/index.html | 26 ++++++++++++++++++++++----
 server.js         | 21 +++++++++++++++++++++
 2 files changed, 43 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index 1a5322a..5d6ac04 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1478,6 +1478,18 @@ async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let
   for(let i=0;i<vids.length;i++){ $('#addNote').textContent=`🎥 Uploading video ${i+1}/${vids.length}…`;
     try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; }catch(e){} }
   return ok; }
+// Shopify transcodes video async — poll until every clip is READY (or a couple minutes pass).
+async function pollVideoStatus(product_id, baseMsg){ const started=Date.now();
+  while(Date.now()-started < 150000){
+    await new Promise(r=>setTimeout(r,5000));
+    let s; try{ s=await(await fetch('/api/media-status?product_id='+encodeURIComponent(product_id))).json(); }catch(e){ continue; }
+    if(!s || !s.ok) continue;
+    if(s.all_ready){ $('#addNote').innerHTML=`${baseMsg} · 🎥 ${s.ready} video(s) ready ✓`; toast('🎥 Videos ready on Shopify'); return; }
+    if(s.failed){ $('#addNote').innerHTML=`${baseMsg} · ⚠ ${s.failed} video(s) failed to process`; return; }
+    $('#addNote').innerHTML=`${baseMsg} · 🎥 transcoding ${s.ready}/${s.total}…`;
+  }
+  $('#addNote').innerHTML=`${baseMsg} · 🎥 still transcoding — check Shopify shortly.`;
+}
 async function addPreview(){ if(_addMode==='update') return addUpdate();
   const p=addPayload(false); if(!p.mfr||!p.vendor){ $('#addNote').textContent='Mfr# and Vendor are required.'; return; }
   $('#addNote').textContent='Previewing…'; $('#addResult').innerHTML='';
@@ -1491,8 +1503,11 @@ async function addPreview(){ if(_addMode==='update') return addUpdate();
 async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
   const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(addPayload(true))})).json();
   if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
-  let vmsg=''; if(addVideosList().length && r.product_id){ const n=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${n} video(s)`; }
-  $('#addNote').innerHTML=`✅ Created DRAFT · ${r.dw_sku}${vmsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
+  const hadVideos=addVideosList().length; let vmsg='';
+  if(hadVideos && r.product_id){ const n=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${n} video(s)`; }
+  const baseMsg=`✅ Created DRAFT · ${r.dw_sku}${vmsg}`;
+  $('#addNote').innerHTML=`${baseMsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
+  if(hadVideos && r.product_id) pollVideoStatus(r.product_id, baseMsg);
 }
 // UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
 async function addResolveUpdate(){ const mfr=$('#addMfr').value.trim(); if(!mfr) return;
@@ -1505,8 +1520,11 @@ async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
   const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
   $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
   if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
-  const vn=addVideosList().length ? await uploadVideos(_updatePid,_updateSku) : 0;
-  $('#addNote').innerHTML=`✅ LIVE: ${added} photo(s) + ${vn} video(s) → ${_updateSku}`; toast('✅ Updated '+_updateSku+' live');
+  const hadVideos=addVideosList().length;
+  const vn=hadVideos ? await uploadVideos(_updatePid,_updateSku) : 0;
+  const baseMsg=`✅ LIVE: ${added} photo(s) + ${vn} video(s) → ${_updateSku}`;
+  $('#addNote').innerHTML=baseMsg; toast('✅ Updated '+_updateSku+' live');
+  if(hadVideos) pollVideoStatus(_updatePid, baseMsg);
 }
 $('#addBtn').addEventListener('click',()=>openAddModal());
 $('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
diff --git a/server.js b/server.js
index a5fe4f5..5842d9a 100644
--- a/server.js
+++ b/server.js
@@ -176,6 +176,18 @@ async function shopifyAddVideo(productId, buf, filename, mime) {
   return { ok: true, processing: true }; // Shopify transcodes async
 }
 
+// Poll a product's VIDEO media processing state (Shopify transcodes async: UPLOADED→PROCESSING→READY|FAILED).
+async function shopifyMediaStatus(productId) {
+  const q = `query($id:ID!){ product(id:$id){ media(first:50){ edges{ node{ mediaContentType status } } } } }`;
+  const d = await gql(q, { id: `gid://shopify/Product/${productId}` });
+  const nodes = ((d.data && d.data.product && d.data.product.media && d.data.product.media.edges) || []).map(e => e.node);
+  const vids = nodes.filter(n => n.mediaContentType === 'VIDEO');
+  const ready = vids.filter(v => v.status === 'READY').length;
+  const failed = vids.filter(v => v.status === 'FAILED').length;
+  const processing = vids.length - ready - failed;
+  return { total: vids.length, ready, processing, failed, all_ready: vids.length > 0 && processing === 0 && failed === 0 };
+}
+
 // After a photo lands, make the SKU live IF it passes the activation gate
 // (now has image + a real price + width + a description). Else stays draft.
 async function shopifyActivateIfReady(productId) {
@@ -1448,6 +1460,15 @@ const appHandler = (req, res) => {
     return;
   }
 
+  // Poll a product's video transcode state (Shopify processes uploaded video async).
+  if (u.pathname === '/api/media-status' && req.method === 'GET') {
+    const pid = (u.searchParams.get('product_id') || '').trim();
+    if (!pid) return send(res, 400, { err: 'product_id required' });
+    shopifyMediaStatus(pid).then(s => send(res, 200, Object.assign({ ok: true }, s)))
+      .catch(e => send(res, 200, { ok: false, err: e.message }));
+    return;
+  }
+
   if (u.pathname === '/api/photo' && req.method === 'POST') {
     let body = '';
     req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });

← 4a51d5b Camera-first capture flow + 10-photo/6-video gallery: pill→c  ·  back to Dw Photo Capture  ·  5x: 2 consecutive clean sweeps (0 app defects) — six-way + f 942ce16 →