← 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
A public/js/native-photo.jsM public/simple-instant.htmlM public/simple-minimal.htmlM public/simple-probooth.htmlM public/simple-wizard.html
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 →