โ 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
M data/build.jsonM public/index.htmlM server.js
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 โ