[object Object]

โ† back to Dw Photo Capture

Video uploads: ๐ŸŽฅ Video in gallery โ†’ /api/video runs the Shopify staged-upload + productCreateMedia(VIDEO) pipeline (multipart to GCS), 150MB cap. Verified end-to-end + cleaned test

7a111396d5091737bd24b8b3f7bf1fda6b5cf89e ยท 2026-06-25 10:10:44 -0700 ยท steve@designerwallcoverings.com

Files touched

Diff

commit 7a111396d5091737bd24b8b3f7bf1fda6b5cf89e
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Thu Jun 25 10:10:44 2026 -0700

    Video uploads: ๐ŸŽฅ Video in gallery โ†’ /api/video runs the Shopify staged-upload + productCreateMedia(VIDEO) pipeline (multipart to GCS), 150MB cap. Verified end-to-end + cleaned test
---
 data/build.json   |  5 +++--
 public/index.html | 16 ++++++++++++++++
 server.js         | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 68 insertions(+), 2 deletions(-)

diff --git a/data/build.json b/data/build.json
index ae87041..2cc8f38 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 34,
+  "next": 35,
   "map": {
     "63863152": 27,
     "578af86f": 2,
@@ -32,6 +32,7 @@
     "86060b11": 30,
     "43b1741c": 31,
     "b2f94d6e": 32,
-    "224e5276": 33
+    "224e5276": 33,
+    "3ee9018c": 34
   }
 }
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index b667fba..b237275 100644
--- a/public/index.html
+++ b/public/index.html
@@ -196,6 +196,7 @@
     <div class="gal-title"><b id="galName"></b><span id="galSku"></span></div>
     <label class="gal-add gal-add-multi">โž• Multiple<input type="file" accept="image/*" multiple id="galAddMulti"></label>
     <label class="gal-add">๐Ÿ“ท Add<input type="file" accept="image/*" capture="environment" id="galAdd"></label>
+    <label class="gal-add gal-add-multi" title="Upload a product video">๐ŸŽฅ Video<input type="file" accept="video/*" capture="environment" id="galVid"></label>
   </div>
   <div class="gal-grid" id="galGrid"></div>
 </div>
@@ -501,6 +502,21 @@ $('#galAdd').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!G
   $('#gallery').hidden=true; document.body.style.overflow='';
   shoot(gx,f);
 });
+$('#galVid').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!GAL_X)return; e.target.value='';
+  if(!GAL_X.product_id){ toast('Create the product first'); return; }
+  const mb=(f.size/1048576);
+  if(mb>150){ toast('Video too large ('+mb.toFixed(0)+'MB) โ€” keep under 150MB'); return; }
+  toast('๐ŸŽฅ Uploading video ('+mb.toFixed(1)+'MB)โ€ฆ');
+  const rd=new FileReader();
+  rd.onload=async()=>{
+    try{ const r=await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({dw_sku:GAL_X.dw_sku,product_id:GAL_X.product_id,dataUrl:rd.result})});
+      const d=await r.json();
+      toast(d.ok?'๐ŸŽฅ Video added โ€” Shopify is processing it':('Video failed: '+(d.err||'error')));
+    }catch(err){ toast('Video upload failed โ€” check connection'); }
+  };
+  rd.readAsDataURL(f);
+});
 
 // Send the (possibly edited) photo to the server โ†’ local save + Shopify push + activation gate.
 async function uploadPhoto(x,dataUrl){
diff --git a/server.js b/server.js
index 815f03b..42a2f66 100644
--- a/server.js
+++ b/server.js
@@ -124,6 +124,38 @@ async function shopifyAppendImage(productId, dwSku, base64, idx) {
   return { ok: true, image_id: r.body && r.body.image && r.body.image.id };
 }
 
+// โ”€โ”€ Video upload to a Shopify product (staged upload โ†’ productCreateMedia) โ”€โ”€
+function gqlFull(query, variables) { return shopifyReq('POST', '/graphql.json', { query, variables: variables || {} }); }
+async function shopifyAddVideo(productId, buf, filename, mime) {
+  // 1) reserve a staged upload slot
+  const su = await gqlFull(`mutation($in:[StagedUploadInput!]!){ stagedUploadsCreate(input:$in){
+      stagedTargets{ url resourceUrl parameters{name value} } userErrors{message} } }`,
+    { in: [{ resource: 'VIDEO', filename, mimeType: mime, httpMethod: 'POST', fileSize: String(buf.length) }] });
+  const tgt = su.body && su.body.data && su.body.data.stagedUploadsCreate && su.body.data.stagedUploadsCreate.stagedTargets[0];
+  if (!tgt) return { ok: false, err: 'stagedUploadsCreate failed: ' + JSON.stringify((su.body || {}).errors || (su.body && su.body.data && su.body.data.stagedUploadsCreate && su.body.data.stagedUploadsCreate.userErrors) || '').slice(0, 200) };
+  // 2) multipart POST the bytes to the staged target (GCS)
+  const boundary = '----dwvid' + Buffer.from(filename).toString('hex').slice(0, 12) + buf.length;
+  const pre = [];
+  for (const p of tgt.parameters) pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"\r\n\r\n${p.value}\r\n`);
+  pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`);
+  const body = Buffer.concat([Buffer.from(pre.join(''), 'utf8'), buf, Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')]);
+  const up = await new Promise(resolve => {
+    const U = new URL(tgt.url);
+    const r = https.request({ host: U.host, path: U.pathname + U.search, method: 'POST',
+      headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length } },
+      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })); });
+    r.on('error', e => resolve({ status: 0, err: e.message })); r.write(body); r.end();
+  });
+  if (up.status < 200 || up.status >= 300) return { ok: false, err: `staged upload HTTP ${up.status}: ${(up.body || up.err || '').slice(0, 160)}` };
+  // 3) attach the staged video to the product
+  const cm = await gqlFull(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){
+      media{ status } mediaUserErrors{ message } } }`,
+    { id: `gid://shopify/Product/${productId}`, media: [{ originalSource: tgt.resourceUrl, mediaContentType: 'VIDEO' }] });
+  const errs = cm.body && cm.body.data && cm.body.data.productCreateMedia && cm.body.data.productCreateMedia.mediaUserErrors;
+  if (errs && errs.length) return { ok: false, err: errs.map(e => e.message).join('; ') };
+  return { ok: true, processing: true }; // Shopify transcodes async
+}
+
 // 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) {
@@ -535,6 +567,23 @@ const appHandler = (req, res) => {
     return;
   }
 
+  // Upload a VIDEO to a Shopify product (staged upload โ†’ product media).
+  if (u.pathname === '/api/video' && req.method === 'POST') {
+    let body = '';
+    req.on('data', c => { body += c; if (body.length > 160 * 1024 * 1024) req.destroy(); });
+    req.on('end', async () => {
+      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
+      if (!p.product_id || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl required' });
+      const m = /^data:(video\/\w+);base64,/.exec(p.dataUrl);
+      const mime = (m && m[1]) || 'video/mp4';
+      const buf = Buffer.from(p.dataUrl.replace(/^data:[^,]+,/, ''), 'base64');
+      const safe = String(p.dw_sku || 'video').replace(/[^A-Za-z0-9._-]/g, '');
+      const r = await shopifyAddVideo(p.product_id, buf, `${safe}.${mime.split('/')[1] || 'mp4'}`, mime);
+      return send(res, r.ok ? 200 : 500, { ok: r.ok, processing: r.processing, err: r.err, size: buf.length });
+    });
+    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(); });

โ† 3832493 Crop tool: add aspect-ratio presets (Free / 1:1 / 4:3 / 3:4  ยท  back to Dw Photo Capture  ยท  Multi-pass OCR scan: try every candidate (DW SKU + mfr# + na f482ee7 โ†’