[object Object]

← back to Dw Photo Capture

TK-12162: full-res native-camera capture on all simple-* pages

4d1311b1d0f179a1b4f784e94fcaac76ebd722a2 · 2026-09-24 14:01:12 -0700 · Steve Abrams

getUserMedia video frames are capped by the stream (~1920 wide on iPhone Safari).
New public/js/native-photo.js opens the device Camera app via
<input capture=environment> and returns the real sensor photo: a full copy (JPEG
0.92, up to 4096px long edge, so a whole 12MP shot) for create-item, and a 1600px
copy for /api/extract so the Gemini label read stays fast and cheap.

Each page gets a Full-res button feeding its existing capture flow (Pro Booth
still bakes the 3 sliders into it). Instant's shutter no longer passes the click
event into doCapture. Headless E2E: all 4 pages send 4032x3024 to create-item and
1600x1200 to extract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD

Files touched

Diff

commit 4d1311b1d0f179a1b4f784e94fcaac76ebd722a2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 14:01:12 2026 -0700

    TK-12162: full-res native-camera capture on all simple-* pages
    
    getUserMedia video frames are capped by the stream (~1920 wide on iPhone Safari).
    New public/js/native-photo.js opens the device Camera app via
    <input capture=environment> and returns the real sensor photo: a full copy (JPEG
    0.92, up to 4096px long edge, so a whole 12MP shot) for create-item, and a 1600px
    copy for /api/extract so the Gemini label read stays fast and cheap.
    
    Each page gets a Full-res button feeding its existing capture flow (Pro Booth
    still bakes the 3 sliders into it). Instant's shutter no longer passes the click
    event into doCapture. Headless E2E: all 4 pages send 4032x3024 to create-item and
    1600x1200 to extract.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD
---
 public/js/native-photo.js   | 66 +++++++++++++++++++++++++++++++++++++++++++++
 public/simple-instant.html  | 22 ++++++++++++---
 public/simple-minimal.html  | 27 ++++++++++++++++---
 public/simple-probooth.html | 29 ++++++++++++++++----
 public/simple-wizard.html   | 18 +++++++++++--
 5 files changed, 148 insertions(+), 14 deletions(-)

diff --git a/public/js/native-photo.js b/public/js/native-photo.js
new file mode 100644
index 0000000..8e657c8
--- /dev/null
+++ b/public/js/native-photo.js
@@ -0,0 +1,66 @@
+/*
+ * native-photo.js — full-resolution capture via the device's OWN camera app.
+ *
+ * getUserMedia video frames are capped by the stream (iPhone Safari often delivers ~1920 wide no
+ * matter what we ask for). <input type=file accept=image/* capture=environment> instead opens the
+ * native Camera app and hands back the real sensor photo (12MP / 4032x3024 on a current iPhone).
+ *
+ * NativePhoto.pick() -> Promise<{ full, small, w, h } | null>
+ *   full  — JPEG 0.92, long edge <= FULL_EDGE (4096 keeps a whole 12MP shot; a 48MP shot is scaled
+ *           so the base64 body stays well under the server's 25MB cap). Use for /api/create-item.
+ *   small — JPEG 0.85, long edge <= SMALL_EDGE. Use for /api/extract: Gemini bills big images by
+ *           tile, and label OCR does not need 12MP, so this keeps the read fast and cheap.
+ *   null  — nothing picked, or the photo could not be decoded (callers toast + stay put).
+ * The re-encode through <img> + canvas applies EXIF orientation and turns HEIC into JPEG.
+ * The promise may never settle if the user cancels on an older WebKit (no 'cancel' event), so
+ * callers must not lock their UI while waiting.
+ */
+(function () {
+  var FULL_EDGE = 4096, SMALL_EDGE = 1600;
+  var input = null, pending = null;
+
+  function encode(img, maxEdge, q) {
+    var sw = img.naturalWidth, sh = img.naturalHeight;
+    var k = Math.min(1, maxEdge / Math.max(sw, sh));
+    var c = document.createElement('canvas');
+    c.width = Math.round(sw * k); c.height = Math.round(sh * k);
+    c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
+    return { url: c.toDataURL('image/jpeg', q), w: c.width, h: c.height };
+  }
+
+  function settle(v) { var p = pending; pending = null; if (p) p(v); }
+
+  function ensureInput() {
+    if (input) return input;
+    input = document.createElement('input');
+    input.type = 'file'; input.accept = 'image/*';
+    input.setAttribute('capture', 'environment');
+    input.style.display = 'none';
+    input.addEventListener('cancel', function () { settle(null); });
+    input.addEventListener('change', function () {
+      var f = input.files && input.files[0];
+      input.value = '';
+      if (!f) return settle(null);
+      var src = URL.createObjectURL(f), img = new Image();
+      img.onload = function () {
+        try {
+          var full = encode(img, FULL_EDGE, 0.92), small = encode(img, SMALL_EDGE, 0.85);
+          settle({ full: full.url, small: small.url, w: full.w, h: full.h });
+        } catch (e) { console.error('[native-photo] encode failed', e); settle(null); }
+        URL.revokeObjectURL(src);
+      };
+      img.onerror = function () { URL.revokeObjectURL(src); settle(null); };
+      img.src = src;
+    });
+    document.body.appendChild(input);
+    return input;
+  }
+
+  window.NativePhoto = {
+    FULL_EDGE: FULL_EDGE,
+    pick: function () {
+      settle(null);   // a newer pick supersedes one whose cancel never fired
+      return new Promise(function (res) { pending = res; ensureInput().click(); });
+    }
+  };
+})();
diff --git a/public/simple-instant.html b/public/simple-instant.html
index c0d1524..c761f6a 100644
--- a/public/simple-instant.html
+++ b/public/simple-instant.html
@@ -124,6 +124,8 @@
   .shutter:active{ transform:scale(.9); }
   .shutter .dot{ width:16px;height:16px;border-radius:50%;background:var(--white); box-shadow:0 0 0 2px rgba(0,0,0,.06) inset; }
   .shutter[disabled]{ opacity:.5; pointer-events:none; }
+  .fullres-btn{ margin-top:-8px; border:1px solid var(--line); background:var(--white); color:var(--gold-deep);
+    font:600 13px/1 -apple-system,system-ui,sans-serif; padding:9px 16px; border-radius:99px; cursor:pointer; }
 
   /* ===== FLASH ===== */
   .flash-overlay{ position:fixed; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:40; }
@@ -205,6 +207,7 @@
       </div>
     </div>
     <button class="shutter" id="shutterBtn" aria-label="Take photo"><span class="dot"></span></button>
+    <button class="fullres-btn" id="fullResBtn">📷 Full-res photo</button>
   </div>
 
   <!-- STATE: developing / result card -->
@@ -244,6 +247,7 @@
   <div class="toast" id="toast"></div>
 
 <script src="/js/field-clean.js"></script>
+<script src="/js/native-photo.js"></script>
 <script>
 (function(){
   var $ = function(id){ return document.getElementById(id); };
@@ -318,15 +322,20 @@
     shutterBtn.disabled = !stream;   // re-arm the shutter — the camera stream is still live, no re-init needed
   }
 
-  async function doCapture(){
-    if (busy || !stream) return;
+  async function doCapture(native){
+    if (busy || (!stream && !native)) return;
     busy = true; shutterBtn.disabled = true;
 
+    if (native) {
+      capturedDataUrl = native.full;
+    } else {
+
     var vw = video.videoWidth || 1280, vh = video.videoHeight || 960;
     var maxDim = 2000, scale = Math.min(1, maxDim / Math.max(vw, vh));
     canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
     canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
     capturedDataUrl = canvas.toDataURL('image/jpeg', 0.92);
+    }
 
     // ── SPEED: fire the REAL network call the instant we have pixels — before any
     // flash/develop UI even starts. The cosmetic animation below runs on its own
@@ -336,7 +345,7 @@
     extractAbort = new AbortController();
     var extractPromise = fetch('/api/extract', {
       method:'POST', headers:{'Content-Type':'application/json'},
-      body: JSON.stringify({ dataUrl: capturedDataUrl }), signal: extractAbort.signal
+      body: JSON.stringify({ dataUrl: (native && native.small) || capturedDataUrl }), signal: extractAbort.signal
     }).then(function(r){ return r.json(); }).catch(function(e){
       return { ok:false, err: e.name === 'AbortError' ? 'aborted' : (e.message || 'network') };
     });
@@ -377,7 +386,12 @@
     fVendor.focus({ preventScroll:true });
   }
 
-  shutterBtn.addEventListener('click', doCapture);
+  shutterBtn.addEventListener('click', function(){ doCapture(); });   // never pass the click event as `native`
+  // Full-res: the phone's own Camera app (real sensor photo); a 1600px copy feeds the label read.
+  $('fullResBtn').addEventListener('click', function(){
+    if (busy) return;
+    NativePhoto.pick().then(function(p){ if (p) doCapture(p); });
+  });
 
   retakeBtn.addEventListener('click', function(){
     if (extractAbort) try{ extractAbort.abort(); }catch(e){}
diff --git a/public/simple-minimal.html b/public/simple-minimal.html
index 060f3c8..5523fc5 100644
--- a/public/simple-minimal.html
+++ b/public/simple-minimal.html
@@ -35,6 +35,7 @@
 <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
 <title>DW Photo — Minimal Showroom</title>
 <script src="/js/field-clean.js"></script>
+<script src="/js/native-photo.js"></script>
 <style>
   :root{ --bg:#0f0e0c; --panel:#1b1710; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184;
          --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
@@ -90,6 +91,11 @@
   #shutter:active{transform:scale(.93)}
   #shutter:disabled{background:#3a3428;border-color:#3a3428;opacity:.7}
   #shutter[hidden]{display:none}
+  /* full-res: opens the phone's own Camera app (real sensor photo, not a video frame) */
+  #btnFull{position:absolute;right:16px;bottom:calc(max(20px,env(safe-area-inset-bottom)) + 170px);
+    border:1px solid var(--gold);color:var(--gold);background:rgba(0,0,0,.55);border-radius:999px;
+    padding:10px 16px;font:600 14px/1 -apple-system,system-ui,sans-serif}
+  #shutter[hidden] + #btnFull{display:none}
 
   /* ============ PROCESSING — pulsing gold status, no other chrome ============ */
   #procWrap{position:absolute;left:0;right:0;bottom:0;z-index:25;display:none;flex-direction:column;
@@ -157,7 +163,7 @@
   <select id="vendorOverride"><option value="">Auto-detect from label</option></select>
 </div>
 
-<div id="shutterWrap"><button id="shutter" aria-label="Capture"></button></div>
+<div id="shutterWrap"><button id="shutter" aria-label="Capture"></button><button id="btnFull">📷 Full-res</button></div>
 
 <div id="procWrap">
   <div class="pulse-dot"></div>
@@ -317,6 +323,21 @@
     runPipeline(capturedDataUrl);
   });
 
+  // Full-res: the native Camera app's photo is the product image; a 1600px copy feeds the label read.
+  $('btnFull').addEventListener('click', function(){
+    if (STATE !== 'camera') return;
+    NativePhoto.pick().then(function(p){
+      if (!p) { return; }
+      if (STATE !== 'camera') return;
+      tStart = performance.now();
+      capturedDataUrl = p.full;
+      shotEl.src = capturedDataUrl;
+      shotEl.classList.add('on');
+      toProcessing('Reading label…');
+      runPipeline(capturedDataUrl, p.small);
+    });
+  });
+
   // ── SPEED: the extract → create-item pipeline. /api/extract must return the vendor + mfr#
   // before /api/create-item can be called (create-item hard-requires both), so — with the two
   // endpoints as they exist today — these two calls cannot be parallelized from the client;
@@ -325,8 +346,8 @@
   // /api/create-item PREVIEW call (~290ms measured) that a review-UI would need — this page has
   // no review step, so on a successful read it goes straight from extract's response into the
   // REAL (committing) create-item call, cutting 3 sequential awaited round trips down to 2. ──
-  function runPipeline(dataUrl){
-    fetch('/api/extract', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ dataUrl: dataUrl }) })
+  function runPipeline(dataUrl, extractUrl){
+    fetch('/api/extract', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ dataUrl: extractUrl || dataUrl }) })
       .then(function(r){ return r.json(); })
       .then(function(r){
         var f = (r && r.fields) || {};
diff --git a/public/simple-probooth.html b/public/simple-probooth.html
index 779a5cc..00cbb8f 100644
--- a/public/simple-probooth.html
+++ b/public/simple-probooth.html
@@ -98,6 +98,7 @@
   #shutter:active::after{transform:scale(.86)}
   #shutter:disabled{opacity:.35}
   .side-slot{width:78px;display:flex;justify-content:center}
+  #fullResBtn{white-space:nowrap}
 
   /* iPad / landscape: sliders become a calm right rail, shutter below them */
   @media (orientation:landscape) and (min-width:700px){
@@ -198,7 +199,7 @@
       <output id="oSaturation">0</output>
     </div>
     <div class="shutter-row">
-      <div class="side-slot"></div>
+      <div class="side-slot"><button class="chip" id="fullResBtn">📷 Full-res</button></div>
       <button id="shutter" aria-label="Take photo" disabled></button>
       <div class="side-slot"><span id="sessN"></span></div>
     </div>
@@ -245,6 +246,7 @@
 <script src="/js/capture-pipeline.js"></script>
 <script src="/js/acquire-camera.js"></script>
 <script src="/js/field-clean.js"></script>
+<script src="/js/native-photo.js"></script>
 <script>
 'use strict';
 /* Pro Booth client — see the header comment for the speed architecture. */
@@ -333,12 +335,13 @@ function previewTick(){
 
 /* ── THE BAKE — exactly once per capture, full res, at shutter time ── */
 const MAX_EDGE=2000;   // same 25MB-body-cap guard as the desk tool
-function bakeFrom(source){
+function bakeFrom(source, maxEdge){
   const sw=source.videoWidth||source.naturalWidth||source.width;
   const sh=source.videoHeight||source.naturalHeight||source.height;
   if(!sw||!sh) return null;
   let w=sw,h=sh; const m=Math.max(w,h);
-  if(m>MAX_EDGE){ const k=MAX_EDGE/m; w=Math.round(w*k); h=Math.round(h*k); }
+  const cap=maxEdge||MAX_EDGE;
+  if(m>cap){ const k=cap/m; w=Math.round(w*k); h=Math.round(h*k); }
   const c=document.createElement('canvas'); c.width=w; c.height=h;
   const ctx=c.getContext('2d',{willReadFrequently:true});
   const t0=performance.now();
@@ -393,6 +396,22 @@ function showTiming(){
   if(t.length) console.log('[probooth perf]',t.join(' · '));
 }
 
+/* ── full-res: the phone's own Camera app photo, baked ONCE with the same 3 sliders at up to
+      NativePhoto.FULL_EDGE; the unbaked 1600px copy feeds the label read (OCR ignores tone). ── */
+$('#fullResBtn').addEventListener('click',()=>{
+  NativePhoto.pick().then(p=>{
+    if(!p) return;
+    const img=new Image();
+    img.onload=()=>{
+      perf={shutterT0:performance.now()};
+      const baked=bakeFrom(img, NativePhoto.FULL_EDGE);
+      if(baked) shutterLand(baked, p.small); else toast('Could not process that photo');
+    };
+    img.onerror=()=>toast('Could not read that photo');
+    img.src=p.full;
+  });
+});
+
 /* ── shutter ── */
 $('#shutter').addEventListener('click',()=>{
   if(!live) return;
@@ -403,10 +422,10 @@ $('#shutter').addEventListener('click',()=>{
   if(!url) return toast('Capture failed — try again');
   shutterLand(url);
 });
-function shutterLand(url){
+function shutterLand(url, extractUrl){
   shotUrl=url; extracted={};
   if(!perf.shutterT0) perf={shutterT0:performance.now()};
-  fireExtract(url);                      // ← network starts NOW, before the result screen paints
+  fireExtract(extractUrl||url);                      // ← network starts NOW, before the result screen paints
   $('#shotImg').src=url;
   ['fVendor','fMfr','fName','fColor'].forEach(id=>{ const el=$('#'+id); el.value=''; el.classList.remove('need'); });
   $('#xstat').textContent='Reading the label…'; $('#xstat').classList.remove('on');
diff --git a/public/simple-wizard.html b/public/simple-wizard.html
index 01b14ef..11bc255 100644
--- a/public/simple-wizard.html
+++ b/public/simple-wizard.html
@@ -10,6 +10,7 @@
 <link rel="apple-touch-icon" href="/icon-180.png">
 <link rel="icon" href="/icon-192.png">
 <script src="/js/field-clean.js"></script>
+<script src="/js/native-photo.js"></script>
 <style>
   /* ============================================================================================
      GUIDED WIZARD — TK-12162 concept-guided-wizard
@@ -186,6 +187,7 @@
       </div>
       <div class="actions">
         <button class="btn-primary btn-big" id="btnReady" disabled>I'm Ready →</button>
+        <button class="btn-secondary" id="btnNative">📷 Full-res photo</button>
       </div>
     </section>
 
@@ -350,6 +352,11 @@
   // ---------------------------------------------------------------------------------------------
   // STEP 1 → 2 : "I'm Ready"
   // ---------------------------------------------------------------------------------------------
+  // Full-res: the phone's own Camera app (real sensor photo). Works even if the live camera failed.
+  $('btnNative').addEventListener('click', () => {
+    NativePhoto.pick().then(p => { if (!p) return; mark('captureTap'); grabFrame(p); });
+  });
+
   $('btnReady').addEventListener('click', () => {
     mark('captureTap');
     // hand the SAME live stream to the capture screen's video element — no re-acquire, no flicker.
@@ -378,8 +385,13 @@
     })();
   }
 
-  function grabFrame(){
+  let lastNative = false;   // retake after a native photo returns to step 1, not the live countdown
+  function grabFrame(native){
     $('flashEl').classList.add('go');
+    lastNative = !!native;
+    if (native) {
+      capturedDataUrl = native.full;
+    } else {
     const v = $('video2');
     const canvas = $('snapCanvas');
     // downscale to a sane max edge — keeps the base64 payload small so the (already-fast) round
@@ -390,6 +402,7 @@
     canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
     canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
     capturedDataUrl = canvas.toDataURL('image/jpeg', 0.92);
+    }
     mark('frameGrabbed');
 
     $('reviewImg').src = capturedDataUrl;
@@ -407,7 +420,7 @@
     mark('extractFired');
     extractPromise = fetch('/api/extract', {
       method:'POST', headers:{ 'Content-Type':'application/json' },
-      body: JSON.stringify({ dataUrl: capturedDataUrl })
+      body: JSON.stringify({ dataUrl: (native && native.small) || capturedDataUrl })
     }).then(r => r.json()).then(j => {
       mark('extractResolved');
       extractResult = j;
@@ -426,6 +439,7 @@
   // ---------------------------------------------------------------------------------------------
   $('btnRetake').addEventListener('click', () => {
     extractPromise = null; extractResult = null; capturedDataUrl = null;
+    if (lastNative) { goStep(1); return; }
     goStep(2);
     runCountdown();
   });

← 14e3194 TK-12162: capture at high res on all simple-* pages + route  ·  back to Dw Photo Capture  ·  TK-12162: pre-shot 11-slider adjust panel + auto-save on all 1a2c398 →