← back to Dw Photo Capture

public/js/native-photo.js

67 lines

/*
 * 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(); });
    }
  };
})();