<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<!-- lv-seo:begin (managed by laserview-settings (/opt/laserview-settings/gen.py)) -->
<title>Laser View — Live ComMarker B4 60W MOPA Fiber Laser Cam &amp; LightBurn Settings</title>
<meta name="description" content="Watch Henry Elsner (Hank) III&#x27;s ComMarker B4 60W MOPA fiber laser live, and browse its open LightBurn settings index: stainless steel black anneal &amp; dark brown, aluminum, titanium, brass and more — verified runs vs library settings.">
<link rel="canonical" href="https://laserview.hankelsner.tech/">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<meta name="author" content="Henry Elsner (Hank) III">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Laser View — Henry Elsner (Hank) III">
<meta property="og:url" content="https://laserview.hankelsner.tech/">
<meta property="og:title" content="LaserView">
<meta property="og:description" content="MOPA color, calibration & laser rules">
<meta property="og:image" content="https://laserview.hankelsner.tech/og.jpg">
<meta property="og:image:width" content="1024">
<meta property="og:image:height" content="576">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:alt" content="LaserView">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="LaserView">
<meta name="twitter:description" content="MOPA color, calibration & laser rules">
<meta name="twitter:image" content="https://laserview.hankelsner.tech/og.jpg">
<meta name="twitter:image:alt" content="LaserView">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="alternate" type="application/json" href="/settings/index.json" title="ComMarker B4 60W MOPA settings (JSON)">
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://laserview.hankelsner.tech/#website","name":"Laser View","url":"https://laserview.hankelsner.tech/","description":"Watch Henry Elsner (Hank) III's ComMarker B4 60W MOPA fiber laser live, and browse its open LightBurn settings index: stainless steel black anneal & dark brown, aluminum, titanium, brass and more — verified runs vs library settings.","publisher":{"@id":"https://hankelsner.com/#person"},"inLanguage":"en"},{"@type":"WebPage","@id":"https://laserview.hankelsner.tech/#page","name":"Laser View — Live ComMarker B4 60W MOPA Fiber Laser Cam & LightBurn Settings","url":"https://laserview.hankelsner.tech/","description":"Watch Henry Elsner (Hank) III's ComMarker B4 60W MOPA fiber laser live, and browse its open LightBurn settings index: stainless steel black anneal & dark brown, aluminum, titanium, brass and more — verified runs vs library settings.","image":"https://laserview.hankelsner.tech/assets/seo/og.jpg?v=824881d2","isPartOf":{"@id":"https://laserview.hankelsner.tech/#website"},"about":{"@type":"Thing","name":"ComMarker B4 60W MOPA fiber laser","description":"ComMarker B4 galvo fiber laser marker with a 60 W MOPA source (adjustable pulse width), JCZ controller, driven by LightBurn."},"author":{"@id":"https://hankelsner.com/#person"},"hasPart":{"@id":"https://laserview.hankelsner.tech/settings/#dataset"}},{"@type":"Person","@id":"https://hankelsner.com/#person","name":"Henry Elsner (Hank) III","url":"https://hankelsner.com/","sameAs":["https://hankelsner.tech/"]}]}</script>
<!-- lv-seo:end -->
<meta name="theme-color" content="#0b0b0e">
<style>
  :root { color-scheme: dark; --bg:#0b0b0e; --fg:#f2f2f0; --mut:#9a9aa2; --line:#26262c; --red:#ff3b30; --ok:#34c759; }
  * { box-sizing: border-box; }
  html, body { margin:0; height:100%; background:var(--bg); color:var(--fg);
    font:15px/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; }
  body { display:flex; flex-direction:column; }
  #admin { display:none; align-items:center; justify-content:center; gap:14px; padding:10px 16px;
    background:#16161b; border-bottom:1px solid var(--line); }
  #admin.show { display:flex; }
  #admin .lbl { color:var(--mut); }
  .sw { position:relative; width:56px; height:30px; border-radius:15px; border:0; cursor:pointer;
    background:#3a3a42; transition:background .2s; padding:0; }
  .sw::after { content:""; position:absolute; top:3px; left:3px; width:24px; height:24px; border-radius:50%;
    background:#fff; transition:left .2s; }
  .sw[aria-checked="true"] { background:var(--ok); }
  .sw[aria-checked="true"]::after { left:29px; }
  .sw:disabled { opacity:.5; cursor:wait; }
  #admin .st { font-weight:600; min-width:7.5em; }
  main { flex:1; position:relative; display:grid; place-items:center; min-height:0; }
  video { width:100%; height:100%; object-fit:contain; background:#000; display:block; }
  #overlay { position:absolute; inset:0; display:grid; place-items:center; pointer-events:none; }
  #overlay .msg { font-size:clamp(20px,4vw,34px); font-weight:600; color:var(--mut); letter-spacing:.02em; }
  #pill { position:absolute; top:12px; left:12px; padding:4px 10px; border-radius:999px; font-size:12px;
    font-weight:700; letter-spacing:.08em; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.12); }
  #pill .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--mut); margin-right:6px; vertical-align:1px; }
  #pill.live .dot { background:var(--red); animation:blink 1.4s infinite; }
  @keyframes blink { 50% { opacity:.3; } }
  /* offline fallback (2026-10-03): looping recorded clip + amber "Replay - camera offline" badge */
  #pill.replay { letter-spacing:.02em; font-weight:600; color:#ffd59a; border-color:rgba(255,159,10,.45); }
  #pill.replay .dot { background:#ff9f0a; }
  /* layout 2026-10-03 (Grok executor for Hank): BIG live view. The player spans the full page width on every screen
     and its height follows the stream's real aspect ratio (--lv-ar, set from videoWidth/videoHeight in JS; 16:9 until
     metadata arrives), capped at ~85% of the viewport so a portrait stream stays sane; the video is centred/letterboxed
     inside. Project cards sit in a row BELOW the player. (Replaces the 2026-10-02 70%-wide / 55vh layout; keeps the
     9463bca fix: the <video> is pinned inside its box and can never spill over the content below.) */
  #lvTop { flex:none; display:flex; flex-direction:column; border-bottom:1px solid var(--line); }
  /* layout 2026-10-03b (Grok executor for Hank): the player stays in normal document flow and is sized to the video
     itself: height <= 70vh, width = min(column width, 70vh x aspect) so the box hugs the picture (no letterbox), centred.
     --lv-ar / --lv-arn come from videoWidth/videoHeight (JS); 16:9 until metadata arrives. Top row = video (left) +
     chat (right, #lv-chat) on >=1024px; on narrower screens the chat sits directly under the video. #lv-chat is a slot:
     while it is empty it is hidden and the video column takes the full width. isolation + overflow:hidden keep the pills
     and the Full screen button inside the player box, so it can never cover text or buttons around it. */
  #lvStage { display:grid; grid-template-columns:minmax(0,1fr); background:#000; }
  #lvVid { min-width:0; display:flex; justify-content:center; align-items:flex-start; background:#000; }
  html body #lvTop main { --lv-ar:16 / 9; --lv-arn:1.7778; flex:none; display:block; position:relative; overflow:hidden;
    isolation:isolate; background:#000; margin:0 auto; height:auto; min-height:0; aspect-ratio:var(--lv-ar);
    width:min(100%, calc(70vh * var(--lv-arn))); width:min(100%, calc(70svh * var(--lv-arn)));
    max-height:70vh; max-height:70svh; }
  html body #lvTop main > video { position:absolute; inset:0; width:100%; height:100%; max-height:none; object-fit:contain; }
  html body #lvTop main > video[hidden] { display:none; }
  #lv-chat { min-width:0; background:#0f0f13; border-top:1px solid var(--line); max-height:60vh; max-height:60svh; overflow:auto; }
  #lv-chat:empty { display:none; }
  @media (min-width:1024px) {
    #lvStage:has(> #lv-chat:not(:empty)) { grid-template-columns:minmax(0,1fr) minmax(320px,32%); }
    #lvStage:has(> #lv-chat:not(:empty)) #lvVid { align-items:center; }
    #lv-chat { border-top:0; border-left:1px solid var(--line); max-height:70vh; max-height:70svh; overflow:auto; }
  }
  @media (max-width:600px) { html body #lvTop main { width:min(100%, max(220px, calc(70svh * var(--lv-arn)))); } }
  /* staged player size (2026-10-16, Hank): SMALL by default (compact, does not dominate the page),
     one Enlarge control -> LARGE (the old full-width / 70vh size), then the existing Full screen
     button / double-click -> fullscreen. Classes toggled by #lvsize + the touch-tap handler below. */
  html body #lvTop main.lv-size-small { width:min(360px, 100%, calc(38vh * var(--lv-arn))); width:min(360px, 100%, calc(38svh * var(--lv-arn))); max-height:38vh; max-height:38svh; }
  html body #lvTop main.lv-size-large { width:min(100%, calc(70vh * var(--lv-arn))); width:min(100%, calc(70svh * var(--lv-arn))); max-height:70vh; max-height:70svh; }
  #lvsize { position:absolute; left:12px; bottom:12px; z-index:3; display:inline-flex; align-items:center; gap:6px;
    min-width:44px; height:44px; padding:0 12px; border-radius:10px; border:1px solid rgba(255,255,255,.22);
    background:rgba(0,0,0,.6); color:#fff; font:600 13px/1 ui-sans-serif,system-ui,sans-serif; cursor:pointer;
    -webkit-tap-highlight-color:transparent; }
  #lvsize:hover, #lvsize:focus-visible { background:rgba(0,0,0,.82); border-color:rgba(255,255,255,.5); outline:none; }
  @media (max-width:600px) { #lvsize .t { display:none; } #lvsize::after { content:"\2922"; font-size:18px; } }
  /* fullscreen (Fullscreen API on the player box, so the LIVE / Laser PC pills stay visible) */
  html body #lvTop main:fullscreen { width:100%; height:100%; max-height:none; min-height:0; aspect-ratio:auto; margin:0; }
  html body #lvTop main:-webkit-full-screen { width:100%; height:100%; max-height:none; min-height:0; aspect-ratio:auto; margin:0; }
  /* fallback "expanded" overlay (browsers/phones without element fullscreen and no native video fullscreen) */
  html body #lvTop main.lv-xp { position:fixed; inset:0; z-index:2147483000; width:100%; height:100%; max-height:none;
    min-height:0; aspect-ratio:auto; margin:0; padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
  html.lv-noscroll, html.lv-noscroll body { overflow:hidden !important; }
  #lvfs { position:absolute; right:12px; bottom:12px; z-index:3; display:inline-flex; align-items:center; gap:6px;
    min-width:44px; height:44px; padding:0 12px; border-radius:10px; border:1px solid rgba(255,255,255,.22);
    background:rgba(0,0,0,.6); color:#fff; font:600 13px/1 ui-sans-serif,system-ui,sans-serif; cursor:pointer;
    -webkit-tap-highlight-color:transparent; }
  #lvfs:hover, #lvfs:focus-visible { background:rgba(0,0,0,.82); border-color:rgba(255,255,255,.5); outline:none; }
  #lvfs svg { width:20px; height:20px; display:block; }
  #lvfs .ex { display:none; }
  main.lv-full #lvfs .en { display:none; } main.lv-full #lvfs .ex { display:block; }
  @media (max-width:600px) { #lvfs .t { display:none; } }
  @media (hover:none) and (pointer:coarse) { html body #lvTop main > video { cursor:zoom-in; } }
  #agentpill { position:absolute; top:12px; right:12px; padding:4px 10px; border-radius:999px; font-size:12px; font-weight:600;
    background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.12); color:var(--mut); max-width:70%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  #agentpill .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#55555e; margin-right:6px; vertical-align:1px; }
  #agentpill.on .dot { background:var(--ok); } #agentpill.busy .dot { background:#ff9f0a; }
  /* narrow / portrait player box: keep the two status pills from running into each other */
  #pill { max-width:calc(100% - 24px); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  main[data-orient="portrait"] #agentpill { top:46px; left:12px; right:auto; max-width:calc(100% - 24px); }
  /* the small player (max 360px wide) has no room for both pills on one row either */
  main.lv-size-small #agentpill { top:46px; left:12px; right:auto; max-width:calc(100% - 24px); }
  #lv-projects { background:#0f0f13; border-bottom:1px solid var(--line); padding:10px 12px 12px; min-width:0; }
  #lv-projects .lvp-hd { display:flex; align-items:baseline; gap:8px; margin:0 2px 8px; font-size:14px; }
  #lv-projects .lvp-hd a { margin-left:auto; color:#7cc4ff; font-size:13px; }
  #lv-projects .lvp-n { color:var(--mut); font-size:12px; }
  #lv-projects .lvp-row { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x proximity; padding-bottom:4px; -webkit-overflow-scrolling:touch; }
  #lv-projects .lvp-card { flex:0 0 148px; scroll-snap-align:start; display:flex; flex-direction:column; gap:3px; text-decoration:none; color:var(--fg);
    background:#15151b; border:1px solid var(--line); border-radius:10px; padding:6px; }
  #lv-projects .lvp-card:hover { border-color:#55555e; }
  #lv-projects .lvp-card img, #lv-projects .lvp-noimg { width:100%; aspect-ratio:4/3; object-fit:cover; border-radius:6px; background:#000; display:grid; place-items:center; color:var(--mut); font-size:12px; }
  #lv-projects .lvp-card img.pv { object-fit:contain; background:#fff; }
  #lv-projects .lvp-t { font-size:13px; font-weight:600; line-height:1.25; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
  #lv-projects .lvp-s { font-size:11.5px; color:var(--mut); }
  #lv-projects .lvp-empty { color:var(--mut); font-size:13px; padding:8px 2px; }
  /* downloads (2026-10-03, Grok executor for Hank): public = only what /settings/index.json publishes (metadata-free
     photos, public runs) + the replay clip; private items appear only for the hank login via the Hank-only API. */
  #lv-downloads { flex:none; background:#0f0f13; border-bottom:1px solid var(--line); padding:12px 16px 16px; }
  #lv-downloads .in { max-width:1180px; margin:0 auto; }
  #lv-downloads h2 { font-size:16px; margin:0 0 4px; }
  #lv-downloads .sub { color:var(--mut); font-size:13px; margin:0 0 10px; }
  #lv-downloads .dl-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:10px; }
  #lv-downloads .dl-card { background:#15151b; border:1px solid var(--line); border-radius:10px; padding:10px; min-width:0; }
  #lv-downloads .dl-card.priv { border-color:#6b5a1e; background:#17150c; }
  #lv-downloads .dl-card h3 { font-size:14px; margin:0 0 4px; line-height:1.3; }
  #lv-downloads .dl-meta { color:var(--mut); font-size:12px; margin:0 0 8px; }
  #lv-downloads .dl-row { display:flex; flex-wrap:wrap; gap:6px; margin:4px 0; }
  #lv-downloads .dl-row b { width:100%; font-size:12px; color:var(--mut); font-weight:600; }
  #lv-downloads a.dlb, #lv-downloads button.dlb { display:inline-flex; align-items:center; gap:5px; min-height:34px; padding:6px 11px;
    border-radius:8px; border:1px solid #2f6fb0; background:#123456; color:#fff; font:600 13px/1.1 ui-sans-serif,system-ui,sans-serif;
    text-decoration:none; cursor:pointer; }
  #lv-downloads a.dlb:hover, #lv-downloads button.dlb:hover { background:#1a4a7a; }
  #lv-downloads .tagp { color:#ff9f0a; font-weight:700; font-size:11.5px; letter-spacing:.03em; }
  #lv-downloads img.dl-th { width:100%; max-height:150px; object-fit:cover; border-radius:6px; background:#000; display:block; margin:0 0 6px; }
  @media (min-width:1024px) {
    #lv-projects { padding:12px 16px 14px; }
    #lv-projects .lvp-card { flex-basis:180px; }
  }
</style>
</head>
<body>
<div id="admin" role="region" aria-label="Camera control">
  <span class="lbl">Camera</span>
  <button id="sw" class="sw" role="switch" aria-checked="false" aria-label="Camera on/off"></button>
  <span id="adminst" class="st">…</span>
</div>
<div id="lvTop">
<div id="lvStage">
<div id="lvVid">
<main class="lv-size-small">
  <video id="v" muted autoplay playsinline disablepictureinpicture></video>
  <video id="vr" muted autoplay loop playsinline disablepictureinpicture preload="none" hidden aria-label="Recorded replay - camera offline"></video>
  <div id="overlay"><div class="msg" id="msg">Connecting…</div></div>
  <div id="pill"><span class="dot"></span><span id="pilltxt">OFFLINE</span></div>
  <div id="agentpill" title="Laser PC status"><span class="dot"></span><span id="agenttxt">Laser PC …</span></div>
  <button id="lvsize" type="button" aria-label="Enlarge player" title="Enlarge player"><span class="t">Enlarge</span></button>
  <button id="lvfs" type="button" aria-label="Full screen" title="Full screen"><svg class="en" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="ex" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg><span class="t">Full screen</span></button>
</main>
</div>
<script>
"use strict";
/* staged player size (2026-10-16): #lvsize + touch tap cycle small <-> large; fullscreen stays #lvfs / double-click. */
(function () {
  var box = document.querySelector("#lvTop main"), btn = document.getElementById("lvsize");
  if (!box || !btn) return;
  function sync() {
    var large = box.classList.contains("lv-size-large");
    var tx = btn.querySelector(".t"); if (tx) tx.textContent = large ? "Small" : "Enlarge";
    btn.setAttribute("aria-label", large ? "Shrink player to small" : "Enlarge player");
    btn.title = large ? "Shrink player to small" : "Enlarge player";
  }
  window.__lvToggleSize = function () {
    var large = box.classList.toggle("lv-size-large");
    box.classList.toggle("lv-size-small", !large);
    sync();
  };
  btn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); window.__lvToggleSize(); });
  sync();
})();
</script>
<aside id="lv-chat" aria-label="Chat"></aside>
</div>
<!-- lv-projects:begin (managed by laserview-settings (/opt/laserview-settings/gen.py)) -->
<aside id="lv-projects" aria-label="Laser projects"><div class="lvp-hd"><b>Projects</b><span class="lvp-n">4</span><a href="/projects/">all projects →</a></div><div class="lvp-row"><a class="lvp-card" href="/projects/#run-r1791678197-c67107"><span class="lvp-noimg">no image yet</span><span class="lvp-t">B4 MOPA color test — round 2, stainless 82x52</span><span class="lvp-s">test grid ready to burn</span></a><a class="lvp-card" href="/projects/#run-r1791673218-d0363a"><span class="lvp-noimg">no image yet</span><span class="lvp-t">B4 MOPA color test — round 1, stainless 82x52</span><span class="lvp-s">test grid scanned — colours measured</span></a><a class="lvp-card" href="/projects/#run-r1791055662-b756a5"><img src="/settings/photos/r1791055662-b756a5/p1791055663128-2e73c2-640.jpg" alt="" loading="lazy"><span class="lvp-t">Duck business cards — black anodized aluminum</span><span class="lvp-s">verified result</span></a><a class="lvp-card" href="/projects/#black-anneal-test-grid"><img src="/projects/img/black-anneal-test-grid.png?v=41c763c9" alt="" loading="lazy" class=pv><span class="lvp-t">Black anneal test grid (stainless)</span><span class="lvp-s">ran — result not recorded yet</span></a></div></aside>
<!-- lv-projects:end -->
</div>
<section id="lv-downloads" aria-label="Downloads"><div class="in">
  <h2>Downloads</h2>
  <p class="sub">Result photos (GPS / EXIF location data removed), videos and the burn profile (settings) of every published project — JSON, a plain-text LightBurn settings sheet, and a LightBurn library file (.clb, check the values after importing).</p>
  <div class="dl-grid" id="dlVideos"></div>
  <div class="dl-grid" id="dlGrid" style="margin-top:10px"><span class="sub">loading…</span></div>
  <div class="dl-grid" id="dlPriv" style="margin-top:10px"></div>
</div></section>
<script src="/vendor/hls.min.js"></script>
<script>
"use strict";
(function () {
  var SRC = "/laser/index.m3u8";
  /* offline fallback (2026-10-03, Grok executor for Hank). Front-end only.
     When the live stream is not up / fails / stalls, a looping recorded clip (CLIP, muted autoplay loop playsinline)
     plays in a second <video> on top with a "Replay - camera offline" badge. The live player keeps retrying underneath
     (every ~15 s, plus a direct playlist probe every 15 s) and replaces the replay as soon as it is actually playing.
     CLIP is only used if it exists and is served as video/* (Caddy's SPA fallback returns index.html otherwise);
     it is re-checked every minute while missing, so a freshly recorded clip shows up without a reload.
     Re-record: sudo /root/claudecode/laserview/tools/record-offline-loop.sh (on the VPS, while the camera is live). */
  var CLIP = "/media/offline-loop.mp4";
  var PROBE_MS = 15000, CONNECT_MS = 20000, STALL_MS = 10000;
  var v = document.getElementById("v"), vr = document.getElementById("vr"), msg = document.getElementById("msg"),
      overlay = document.getElementById("overlay"), pill = document.getElementById("pill"),
      pilltxt = document.getElementById("pilltxt"), sw = document.getElementById("sw"),
      adminst = document.getElementById("adminst"), box = v.parentNode;
  var hls = null, playing = false, state = null, retryAt = 0, bootAt = Date.now();
  var probeOk = false, liveSince = 0, lastT = -1, lastTAt = 0, mode = "";
  var clip = { ok: false, url: "", checkedAt: 0 };

  function show(text) { overlay.style.display = text ? "grid" : "none"; msg.textContent = text || ""; }
  function setPill(kind) {
    pill.className = kind;
    pilltxt.textContent = kind === "live" ? "LIVE" : kind === "replay" ? "Replay \u2013 camera offline" : "OFFLINE";
  }
  function play(el) { var p = el.play(); if (p && p.catch) p.catch(function () {}); }
  function setMode(m) {
    if (m === mode) return;
    mode = m; box.dataset.mode = m;
    try { box.dispatchEvent(new Event("lv-mode")); } catch (e) {}
  }

  // ---- live (HLS) ----
  function liveActive() { return !!(hls || v.getAttribute("src")); }
  function stop() {
    if (hls) { hls.destroy(); hls = null; }
    v.removeAttribute("src"); try { v.load(); } catch (e) {}
    playing = false; liveSince = 0;
  }
  function failLive() {
    stop(); probeOk = false;
    retryAt = Date.now() + (clip.ok ? PROBE_MS : 3000);
    render();
  }
  function start() {
    if (liveActive() || Date.now() < retryAt) return;
    liveSince = lastTAt = Date.now(); lastT = -1;
    if (window.Hls && Hls.isSupported()) {
      hls = new Hls({ lowLatencyMode: true, liveSyncDurationCount: 2, maxLiveSyncPlaybackRate: 1.5,
                      manifestLoadingMaxRetry: 2, levelLoadingMaxRetry: 4 });
      hls.on(Hls.Events.ERROR, function (_, d) { if (d.fatal) failLive(); });
      hls.loadSource(SRC);
      hls.attachMedia(v);
    } else if (v.canPlayType("application/vnd.apple.mpegurl")) {
      v.src = SRC;
    } else { liveSince = 0; return; }
    play(v);
  }
  v.addEventListener("playing", function () { if (liveActive()) { playing = true; lastTAt = Date.now(); } render(); });
  v.addEventListener("waiting", function () { render(); });
  v.addEventListener("timeupdate", function () { if (v.currentTime !== lastT) { lastT = v.currentTime; lastTAt = Date.now(); } });
  v.addEventListener("error", function () { if (v.error && liveActive()) failLive(); });

  // ---- replay clip ----
  function checkClip(force) {
    var now = Date.now();
    if (!force && now - clip.checkedAt < (clip.ok ? 600000 : 60000)) return;
    clip.checkedAt = now;
    fetch(CLIP, { method: "HEAD", cache: "no-store" }).then(function (r) {
      var ok = r.ok && /^video\//i.test(r.headers.get("content-type") || "");
      var tag = (r.headers.get("etag") || r.headers.get("last-modified") || "").replace(/[^0-9A-Za-z]/g, "").slice(-16);
      var url = ok ? CLIP + (tag ? "?v=" + tag : "") : "";
      if (ok !== clip.ok || url !== clip.url) { clip.ok = ok; clip.url = url; render(); }
    }, function () {});
  }
  function replayOn() {
    if (vr.getAttribute("src") !== clip.url) { vr.src = clip.url; }
    if (vr.hidden) { vr.hidden = false; }
    if (vr.paused) play(vr);
    setMode("replay");
  }
  function replayOff() {
    if (!vr.hidden) {
      try { if (vr.webkitDisplayingFullscreen && vr.webkitExitFullscreen) vr.webkitExitFullscreen(); } catch (e) {}
      vr.hidden = true;
    }
    if (!vr.paused) vr.pause();
  }
  vr.addEventListener("error", function () {
    if (!vr.error) return;
    clip.ok = false; clip.url = ""; clip.checkedAt = Date.now();   // re-checked in a minute
    vr.removeAttribute("src"); try { vr.load(); } catch (e) {}
    render();
  });

  function render() {
    var en = state ? !!state.enabled : true, up = state ? !!state.live : false;
    var wantLive = en && (up || probeOk);
    if (wantLive) start(); else if (liveActive()) stop();
    if (liveActive() && playing) {
      replayOff(); setMode("live"); setPill("live"); show("");
    } else if (clip.ok && (state || probeOk || Date.now() - bootAt > 4000)) {
      replayOn(); setPill("replay"); show("");
    } else {
      replayOff(); setMode(""); setPill("");
      if (state) show(!en ? "Camera off" : (up || liveActive()) ? "Connecting…" : "Camera starting…");
    }
    if (state && sw.dataset.busy !== "1") {
      sw.setAttribute("aria-checked", state.enabled ? "true" : "false");
      adminst.textContent = state.enabled ? (state.live ? "ON · live" : "ON · starting") : "OFF";
    }
  }

  // watchdog: live never got going (only when a replay is available to fall back to) or stalled -> replay
  setInterval(function () {
    if (!liveActive() || document.hidden) return;
    var now = Date.now();
    if (!playing) { if (clip.ok && liveSince && now - liveSince > CONNECT_MS) failLive(); }
    else if (!v.paused && now - lastTAt > STALL_MS) failLive();
  }, 2000);
  document.addEventListener("visibilitychange", function () {
    if (document.hidden) return;
    lastTAt = Date.now();
    if (liveActive() && v.paused) play(v);
    if (!vr.hidden && vr.paused) play(vr);
  });

  // every ~15 s while not live: probe the playlist directly (MediaMTX answers 404 until the path is ready)
  function probe() {
    checkClip(false);
    var p = liveActive() ? Promise.resolve(null)
      : fetch(SRC, { cache: "no-store", credentials: "same-origin" }).then(function (r) {
          return r.ok ? r.text().then(function (t) { return t.indexOf("#EXTM3U") === 0; }) : false;
        });
    p.then(function (ok) {
      if (ok === null) return;
      if (ok && !probeOk) retryAt = 0;
      probeOk = ok; render();
    }, function () { probeOk = false; }).then(function () { setTimeout(probe, PROBE_MS); });
  }
  checkClip(true);
  setTimeout(probe, 1500);

  function poll() {
    fetch("/api/state", { cache: "no-store" }).then(function (r) { return r.json(); })
      .then(function (s) { state = s; render(); }, function () {})
      .then(function () { setTimeout(poll, 3000); });
  }
  poll();

  // laser PC status (public: which machine, online/offline, idle/framing/busy - never job names or files)
  var ap = document.getElementById("agentpill"), at = document.getElementById("agenttxt");
  function pollAgent() {
    fetch("/api/agents/public", { cache: "no-store" }).then(function (r) { return r.json(); }).then(function (d) {
      var a = d && d.active; if (!a) return;
      var st = a.online ? (a.state || "online") : "offline";
      ap.className = a.online ? (st === "busy" ? "busy" : "on") : "";
      at.textContent = "Laser PC: " + (a.label || "?") + " · " + (a.online ? "online · " + st : "offline");
    }, function () {}).then(function () { setTimeout(pollAgent, 10000); });
  }
  pollAgent();

  // Hank-only control. The server enforces this independently (401/403 for anyone else).
  fetch("/api/me", { credentials: "same-origin", cache: "no-store" })
    .then(function (r) { return r.json(); })
    .then(function (me) {
      if (!me || !me.hank) return;
      document.getElementById("admin").classList.add("show");
      // Laser + connection panels live only behind the Hank-only API (never in this public file).
      var sc = document.createElement("script"); sc.src = "/api/admin/ui.js?t=" + Date.now(); document.body.appendChild(sc);
    }, function () {});

  sw.addEventListener("click", function () {
    if (!state) return;
    var want = !state.enabled;
    sw.disabled = true; sw.dataset.busy = "1";
    sw.setAttribute("aria-checked", want ? "true" : "false");
    adminst.textContent = want ? "Turning on…" : "Turning off…";
    fetch("/api/toggle", { method: "POST", credentials: "same-origin",
      headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: want }) })
      .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || r.status); return j; }); })
      .then(function (j) { state.enabled = j.enabled; state.live = false; },
            function (e) { adminst.textContent = "Error: " + e.message; })
      .then(function () { sw.disabled = false; sw.dataset.busy = ""; setTimeout(render, 1500); });
  });
})();
</script>
<script>
/* player sizing + fullscreen (2026-10-03, Grok executor for Hank). Front-end only.
   - aspect ratio follows the stream's intrinsic size (loadedmetadata / resize), so a rotated (portrait) phone-cam
     stream and the fixed (landscape) one both fit; adapts live when the dimensions change. Default 16:9.
   - Full screen button: Fullscreen API on the player box (+ webkit prefixes); iPhone: video.webkitEnterFullscreen();
     otherwise an "expanded" fixed overlay.
   - Phones/tablets: tapping the video enlarges it (tap again / Esc to leave); desktop: double-click toggles. */
(function () {
  "use strict";
  var box = document.querySelector("#lvTop main") || document.querySelector("main"),
      v = document.getElementById("v"), vr = document.getElementById("vr"), btn = document.getElementById("lvfs");
  if (!box || !v || !btn) return;
  var d = document, de = d.documentElement, lastAr = "";
  function cur() { return (vr && !vr.hidden) ? vr : v; }  // the visible player: live, or the offline replay clip

  function applyAr() {
    var e = cur(), w = e.videoWidth, h = e.videoHeight;
    if (!w || !h) return;
    var ar = w + " / " + h;
    if (ar === lastAr) return;
    lastAr = ar;
    box.style.setProperty("--lv-ar", ar);
    box.style.setProperty("--lv-arn", String(Math.round(w / h * 10000) / 10000));
    box.dataset.orient = h > w ? "portrait" : "landscape";
  }
  ["loadedmetadata", "resize", "playing", "loadeddata"].forEach(function (ev) {
    v.addEventListener(ev, applyAr); if (vr) vr.addEventListener(ev, applyAr);
  });
  box.addEventListener("lv-mode", applyAr);
  setInterval(applyAr, 2000); // belt and braces: some MSE pipelines change size without a resize event

  function fsEl() { return d.fullscreenElement || d.webkitFullscreenElement || d.webkitCurrentFullScreenElement || null; }
  function fsEnabled() {
    return !!((d.fullscreenEnabled || d.webkitFullscreenEnabled) && (box.requestFullscreen || box.webkitRequestFullscreen || box.webkitRequestFullScreen));
  }
  function isFull() { return !!fsEl() || box.classList.contains("lv-xp") || !!v.webkitDisplayingFullscreen || !!(vr && vr.webkitDisplayingFullscreen); }
  function sync() {
    var on = isFull();
    box.classList.toggle("lv-full", on);
    btn.setAttribute("aria-label", on ? "Exit full screen" : "Full screen");
    btn.title = on ? "Exit full screen" : "Full screen";
    var t = btn.querySelector(".t"); if (t) t.textContent = on ? "Exit full screen" : "Full screen";
  }
  function expand(on) {
    box.classList.toggle("lv-xp", on);
    de.classList.toggle("lv-noscroll", on);
    sync();
  }
  function lockLandscape() {
    try {
      if (box.dataset.orient !== "portrait" && screen.orientation && screen.orientation.lock)
        screen.orientation.lock("landscape").catch(function () {});
    } catch (e) {}
  }
  function nativeVideoFs() {
    var e0 = cur();
    if (e0.webkitEnterFullscreen && (e0.webkitSupportsFullscreen !== false) && e0.readyState >= 1) {
      try { e0.webkitEnterFullscreen(); return true; } catch (e) {}
    }
    return false;
  }
  function enter() {
    if (fsEnabled()) {
      var r = null;
      try {
        r = box.requestFullscreen ? box.requestFullscreen({ navigationUI: "hide" })
          : (box.webkitRequestFullscreen ? box.webkitRequestFullscreen() : box.webkitRequestFullScreen());
      } catch (e) { r = Promise.reject(e); }
      if (r && r.then) r.then(lockLandscape, function () { if (!nativeVideoFs()) expand(true); });
      else lockLandscape();
      return;
    }
    if (nativeVideoFs()) return;  // iPhone Safari: native player full screen
    expand(true);                  // last resort: page-filling overlay
  }
  function exit() {
    if (box.classList.contains("lv-xp")) { expand(false); return; }
    if (fsEl()) {
      try { (d.exitFullscreen || d.webkitExitFullscreen || d.webkitCancelFullScreen).call(d); } catch (e) {}
      try { if (screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); } catch (e) {}
      return;
    }
    [v, vr].forEach(function (el) { if (el && el.webkitDisplayingFullscreen && el.webkitExitFullscreen) { try { el.webkitExitFullscreen(); } catch (e) {} } });
  }
  function toggle() { if (isFull()) exit(); else enter(); }
  function resume() { var e = cur(); if (e.paused && (e.currentSrc || e.getAttribute("src"))) { var p = e.play(); if (p && p.catch) p.catch(function () {}); } }

  btn.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); resume(); toggle(); });
  d.addEventListener("fullscreenchange", sync);
  d.addEventListener("webkitfullscreenchange", sync);
  [v, vr].forEach(function (el) {
    if (!el) return;
    el.addEventListener("webkitbeginfullscreen", sync);
    el.addEventListener("webkitendfullscreen", function () { sync(); setTimeout(resume, 50); }); // iOS pauses on exit
  });
  d.addEventListener("keydown", function (e) { if ((e.key === "Escape" || e.key === "Esc") && box.classList.contains("lv-xp")) expand(false); });

  var touchUi = window.matchMedia ? window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 760px)") : null;
  box.addEventListener("click", function (e) {
    if (e.target.closest && e.target.closest("button, a, input, select, label")) return;
    resume();                                   // a tap still (re)starts playback if autoplay was blocked
    if (touchUi && touchUi.matches && window.__lvToggleSize) window.__lvToggleSize(); else if (touchUi && touchUi.matches) toggle(); // phones/tablets: tap = small -> large (staged); Full screen button / double-click = largest
  });
  box.addEventListener("dblclick", function (e) {
    if (touchUi && touchUi.matches) return;
    if (e.target.closest && e.target.closest("button, a, input, select, label")) return;
    toggle();
  });
  sync();
})();
</script>
<script>
/* downloads (2026-10-03, Grok executor for Hank). Front-end only, no new server endpoints.
   Public: everything comes from /settings/index.json, which gen.py already filters (no private/hidden runs, jobs,
   approvals or photos) and whose photos are re-encoded metadata-free (no EXIF/GPS) - plus the replay clip and any
   videos listed in /media/videos.json. Burn profiles are built in the browser from that public data: JSON, a plain-text
   LightBurn settings sheet and a LightBurn library (.clb).
   Hank only (signed in): runs that are NOT public (private / hidden / not yet published) come from the Hank-only
   GET /api/runs; their photos download through the Hank-only GET /api/photos/<pid>/1600 (metadata-free web copy). */
(function () {
  "use strict";
  var $ = function (id) { return document.getElementById(id); };
  var grid = $("dlGrid"), vids = $("dlVideos"), priv = $("dlPriv");
  if (!grid) return;
  var MACHINE = "ComMarker B4 60W MOPA fiber laser (JCZ controller, LightBurn), 126 x 126 mm field";
  function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
  function slug(s) { return String(s || "entry").toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "entry"; }
  function n(v) { return (typeof v === "number" && isFinite(v)) ? v : null; }
  function day(iso) { return iso ? String(iso).slice(0, 10) : ""; }
  function save(name, text, type) {
    var b = new Blob([text], { type: type || "application/octet-stream" }), u = URL.createObjectURL(b), a = document.createElement("a");
    a.href = u; a.download = name; document.body.appendChild(a); a.click();
    setTimeout(function () { URL.revokeObjectURL(u); a.remove(); }, 2000);
  }
  // ---- normalised layer list: [{name, mode, power_pct, power_max_pct, min_power_pct, speed_mm_s, frequency_khz, pulse_width_ns, line_interval_mm, dpi, hatch_angle_deg, cross_hatch, passes}]
  function layersOf(e) {
    if (e.layers && e.layers.length) return e.layers;
    var p = e.params || {};
    if (!Object.keys(p).length) return [];
    return [{ name: "Layer 1", mode: p.mode || null, power_pct: n(p.power_pct), min_power_pct: n(p.min_power_pct), speed_mm_s: n(p.speed_mm_s),
              frequency_khz: n(p.frequency_khz), pulse_width_ns: n(p.pulse_width_ns), line_interval_mm: n(p.hatch_spacing_mm),
              hatch_angle_deg: n(p.hatch_angle_deg), cross_hatch: p.cross_hatch, passes: n(p.passes) }];
  }
  function rawParams(p) {   // Hank-only API: runs.json params use LightBurn/MarkParam keys
    p = p || {};
    if (p.cells) return { mode: "test grid", note: p.summary || "" };
    var M = { Scan: "Fill (Scan)", Cut: "Line (Cut)", Image: "Image (raster)" };
    return { mode: M[p.type] || p.type || null, power_pct: n(p.maxPower), min_power_pct: n(p.minPower), speed_mm_s: n(p.speed),
             frequency_khz: n(p.frequency) ? p.frequency / 1000 : null, pulse_width_ns: n(p.QPulseWidth), hatch_spacing_mm: n(p.interval),
             hatch_angle_deg: n(p.angle), cross_hatch: p.crossHatch, passes: n(p.numPasses) };
  }
  function profile(e) {
    return { format: "laserview-burn-profile/1", source: location.origin + "/", downloaded: new Date().toISOString(),
             machine: MACHINE, id: e.id, title: e.title, workpiece: e.workpiece || null, material_group: e.group || null,
             date: e.at || null, status: e.status || null, result: e.result || null, setting: e.setting || null,
             layers: layersOf(e), grid_summary: e.grid || null, page: e.url || null,
             photos: (e.photos || []).map(function (p) { return p.url; }), private: !!e.private };
  }
  function txt(e) {
    var L = layersOf(e), o = [];
    o.push("Laser View burn profile - " + e.title);
    o.push("Machine:   " + MACHINE);
    if (e.workpiece) o.push("Workpiece: " + e.workpiece);
    if (e.at) o.push("Date:      " + day(e.at));
    if (e.status) o.push("Status:    " + e.status);
    if (e.result) o.push("Result:    " + e.result);
    if (e.setting) o.push("Setting:   " + e.setting);
    if (e.grid) o.push("Test grid: " + e.grid);
    if (e.url) o.push("Page:      " + e.url);
    o.push("");
    o.push("LightBurn Cut Settings (enter per layer; values not listed were not recorded - LightBurn defaults)");
    L.forEach(function (l, i) {
      o.push("");
      o.push("[" + (l.name || "Layer " + (i + 1)) + "]");
      function row(k, v) { if (v !== null && v !== undefined && v !== "") o.push("  " + (k + ":                    ").slice(0, 22) + v); }
      row("Mode", l.mode);
      row("Power (%)", l.power_pct != null ? l.power_pct + (l.power_max_pct != null ? "  (max " + l.power_max_pct + ")" : "") : null);
      row("Min power (%)", l.min_power_pct);
      row("Speed (mm/s)", l.speed_mm_s);
      row("Frequency (kHz)", l.frequency_khz);
      row("Q-Pulse width (ns)", l.pulse_width_ns);
      row("Line interval (mm)", l.line_interval_mm);
      row("DPI", l.dpi);
      row("Hatch angle (deg)", l.hatch_angle_deg);
      row("Cross-hatch", l.cross_hatch == null ? null : (l.cross_hatch ? "yes" : "no"));
      row("Passes", l.passes);
      row("Notes", l.notes);
    });
    if (!L.length) o.push("  (no per-layer parameters recorded for this entry)");
    o.push("");
    o.push("Always test on scrap first - results depend on alloy, finish, focus and lens.");
    return o.join("\r\n") + "\r\n";
  }
  function xa(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; }); }
  function clb(e) {
    var L = layersOf(e), mat = e.groupLabel || e.workpiece || "Laser View", desc = (e.title || "entry").replace(/[\/\\]/g, "-").slice(0, 60);
    var o = ['<?xml version="1.0" encoding="UTF-8"?>', '<LightBurnLibrary DisplayName="Laser View - ' + xa(desc) + '">', '    <Material name="' + xa(mat) + '">'];
    L.forEach(function (l, i) {
      var nm = l.name || ("Layer " + (i + 1)), mode = String(l.mode || "");
      var type = /image|dither|raster/i.test(mode) ? "Image" : /line|cut/i.test(mode) ? "Cut" : "Scan";
      var lp = [mat, desc, nm].map(function (x) { return String(x).replace(/[\/\\]/g, "-"); }).join("/");
      o.push('        <Entry Thickness="-1.0000" Desc="' + xa(desc + " - " + nm) + '" NoThickTitle="Laser View">');
      o.push('            <CutSetting type="' + type + '">');
      function v(k, val) { if (val !== null && val !== undefined && val !== "") o.push('                <' + k + ' Value="' + xa(val) + '"/>'); }
      v("index", i); v("name", nm); v("LinkPath", lp);
      v("minPower", l.min_power_pct != null ? l.min_power_pct : l.power_pct); v("maxPower", l.power_pct);
      v("speed", l.speed_mm_s); v("frequency", l.frequency_khz != null ? Math.round(l.frequency_khz * 1000) : null);
      v("QPulseWidth", l.pulse_width_ns);
      v("interval", l.line_interval_mm != null ? l.line_interval_mm : (l.dpi ? Math.round(25.4 / l.dpi * 10000) / 10000 : null));
      v("angle", l.hatch_angle_deg); v("crossHatch", l.cross_hatch == null ? null : (l.cross_hatch ? 1 : 0));
      v("numPasses", l.passes);
      o.push('            </CutSetting>');
      o.push('        </Entry>');
    });
    o.push('    </Material>', '</LightBurnLibrary>', '');
    return o.join("\n");
  }
  var entries = {};
  function card(e) {
    entries[e.key] = e;
    var base = slug(e.title) + "-" + day(e.at);
    var th = e.photos && e.photos[0] ? '<img class="dl-th" loading="lazy" alt="" src="' + esc(e.photos[0].thumb || e.photos[0].url) + '">' : "";
    var ph = (e.photos || []).map(function (p, i) {
      return '<a class="dlb" href="' + esc(p.url) + '" download="' + esc(base + "-photo-" + (i + 1) + ".jpg") + '">\u2B07 ' +
        esc(p.label || ("Photo " + (i + 1))) + '</a>';
    }).join("");
    return '<article class="dl-card' + (e.private ? " priv" : "") + '">' + th +
      '<h3>' + (e.private ? '<span class="tagp">PRIVATE \u2014 only you see this</span><br>' : "") + esc(e.title) + '</h3>' +
      '<p class="dl-meta">' + esc([day(e.at), e.workpiece, e.status].filter(Boolean).join(" \u00b7 ")) +
      (e.url ? ' \u00b7 <a href="' + esc(e.url) + '" style="color:#7cc4ff">details</a>' : "") + '</p>' +
      (ph ? '<div class="dl-row"><b>Photos (GPS/EXIF removed)</b>' + ph + '</div>' : "") +
      '<div class="dl-row"><b>Burn profile</b>' +
      '<button type="button" class="dlb" data-dl="json" data-k="' + esc(e.key) + '">\u2B07 JSON</button>' +
      '<button type="button" class="dlb" data-dl="txt" data-k="' + esc(e.key) + '">\u2B07 Settings sheet (.txt)</button>' +
      '<button type="button" class="dlb" data-dl="clb" data-k="' + esc(e.key) + '">\u2B07 LightBurn library (.clb)</button></div>' +
      '</article>';
  }
  document.addEventListener("click", function (ev) {
    var b = ev.target.closest && ev.target.closest("#lv-downloads button[data-dl]");
    if (!b) return;
    var e = entries[b.getAttribute("data-k")]; if (!e) return;
    var base = slug(e.title) + "-" + day(e.at), k = b.getAttribute("data-dl");
    if (k === "json") save(base + ".json", JSON.stringify(profile(e), null, 2) + "\n", "application/json");
    else if (k === "txt") save(base + "-lightburn-settings.txt", txt(e), "text/plain");
    else save(base + ".clb", clb(e), "application/xml");
  });
  var LABELS = {};
  function fromPublic(r) {
    return { key: "pub-" + r.id, id: r.id, title: r.job || r.id, workpiece: r.workpiece, group: r.group, groupLabel: LABELS[r.group],
             at: r.at, status: r.status === "verified" ? "verified result" : "result not recorded yet", result: r.result,
             setting: r.setting_name, params: r.params, layers: r.layers, grid: r.grid_summary, url: r.url,
             photos: (r.photos || []).map(function (p) { return { url: p.url, thumb: p.thumbnail,
               label: p.type === "owner photo" ? "Photo" : "Webcam " + (p.stage || "snapshot") }; }) };
  }
  // public videos: the replay clip + optional /media/videos.json [{url,title,date,duration_s}]
  function vidCard(list) {
    vids.innerHTML = '<article class="dl-card"><h3>Videos</h3><p class="dl-meta">Recorded from the live camera.</p><div class="dl-row">' +
      list.map(function (v) {
        var nm = (v.url.split("/").pop() || "video.mp4").split("?")[0];
        return '<a class="dlb" href="' + esc(v.url) + '" download="' + esc(nm) + '">\u2B07 ' + esc(v.title || nm) +
          (v.duration_s ? " (" + Math.floor(v.duration_s / 60) + ":" + ("0" + Math.round(v.duration_s % 60)).slice(-2) + ")" : "") + '</a>';
      }).join("") + '</div></article>';
  }
  var replay = { url: "/media/offline-loop.mp4", title: "Burn replay clip (MP4)" };
  // the replay clip is only listed when it is really there (served as video/*, not the SPA index.html fallback)
  var hasReplay = fetch(replay.url, { method: "HEAD", cache: "no-store" }).then(function (r) {
    return r.ok && /^video\//i.test(r.headers.get("content-type") || "");
  }, function () { return false; });
  fetch("/media/videos.json", { cache: "no-store" }).then(function (r) {
    return r.ok && /json/i.test(r.headers.get("content-type") || "") ? r.json() : null;
  }, function () { return null; }).then(function (j) {
    var list = (j && Array.isArray(j.videos)) ? j.videos.filter(function (v) { return v && typeof v.url === "string" && /^\/media\/[A-Za-z0-9._-]+\.(mp4|webm|mov)$/.test(v.url); }) : [];
    return hasReplay.then(function (ok) {
      list = list.filter(function (v) { return v.url !== replay.url; });
      if (ok) list.unshift(replay);
      if (list.length) vidCard(list);
    });
  });
  var pubIds = {};
  var pub = fetch("/settings/index.json", { cache: "no-cache" }).then(function (r) { return r.json(); }).then(function (d) {
    (d.materials || []).forEach(function (m) { LABELS[m.slug] = m.label; });
    var runs = (d.runs || []).slice();
    runs.forEach(function (r) { pubIds[r.id] = 1; });
    // gallery entries first: owner photos, then verified results, then the rest; newest first within
    runs.sort(function (a, b) {
      var sa = ((a.photos || []).some(function (p) { return p.type === "owner photo"; }) ? 0 : 2) + (a.status === "verified" ? 0 : 1);
      var sb = ((b.photos || []).some(function (p) { return p.type === "owner photo"; }) ? 0 : 2) + (b.status === "verified" ? 0 : 1);
      return sa - sb || String(b.at || "").localeCompare(String(a.at || ""));
    });
    grid.innerHTML = runs.length ? runs.map(function (r) { return card(fromPublic(r)); }).join("") : '<span class="sub">No published entries yet.</span>';
  }, function () { grid.innerHTML = '<span class="sub">Downloads list unavailable right now.</span>'; });
  // Hank only: not-public runs (server answers 401/403 to anyone else; nothing private is in this file)
  fetch("/api/me", { credentials: "same-origin", cache: "no-store" }).then(function (r) { return r.json(); }).then(function (me) {
    if (!me || !me.hank) return;
    return pub.then(function () { return fetch("/api/runs?limit=500", { credentials: "same-origin", cache: "no-store" }); })
      .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
      .then(function (j) {
        var rs = (j.runs || []).filter(function (r) { return r && r.id && !pubIds[r.id]; });
        var cards = rs.map(function (r) {
          var ps = (r.photos || []);
          return card({ key: "priv-" + r.id, id: r.id, private: true, title: (r.job_name || r.job || r.id).trim(), workpiece: r.workpiece || null,
            at: r.at, status: r.note ? "result recorded" : "result not recorded yet", result: r.note || null,
            setting: r.material_name || null, params: rawParams(r.params), layers: r.layers,
            grid: r.params && r.params.cells ? r.params.summary : null, url: null,
            photos: ps.map(function (p, i) { return { url: "/api/photos/" + encodeURIComponent(p.id) + "/1600", thumb: "/api/photos/" + encodeURIComponent(p.id) + "/640",
              label: (p.kind === "upload" ? "Photo " : "Webcam " + (p.stage || "")) + (p.public === false ? " (hidden)" : "") }; }) });
        });
        priv.style.display = "block";
        priv.innerHTML = cards.length ? '<details><summary class="sub" style="cursor:pointer"><span class="tagp">ONLY YOU</span> Private / hidden / unpublished runs (' +
          cards.length + ') — not visible to anyone else</summary><div class="dl-grid" style="margin-top:8px">' + cards.join("") + '</div></details>' : "";
      });
  }).catch(function () {});
})();
</script>
<!-- lv-settings-index:begin (managed by laserview-settings (/opt/laserview-settings/gen.py)) -->
<style>#lv-settings-index{flex:none;padding:18px 16px 26px;border-top:1px solid #26262c;background:#0b0b0e;color:#f2f2f0;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}#lv-settings-index .in{max-width:980px;margin:0 auto}#lv-settings-index h2{font-size:18px;margin:0 0 6px}#lv-settings-index a{color:#7cc4ff}#lv-settings-index nav a{margin-right:12px;white-space:nowrap}</style>
<section id="lv-settings-index" aria-label="ComMarker B4 60W MOPA settings index"><div class="in"><h2>ComMarker B4 60W MOPA fiber laser — open LightBurn settings index</h2><p style="margin:6px 0">This camera watches a <b>ComMarker B4 60W MOPA fiber laser</b> (JCZ controller, LightBurn). Its settings are published as an open index: 93 settings across 17 materials, 1 verified run with recorded results. Library entries are labelled as unverified starting settings.</p><p style="margin:6px 0">Latest verified run: <a href="/settings/aluminum/#r1791055662-b756a5">Black anodized aluminum business cards</a> — Good, crisp white marks (manual-height result). First try at 80% power was way too hot. Image layer: Jarvis dither ~400 DPI, 12% power (max 20), 1500 mm/s, 50 kHz, 1 pass. Text/border: fill, 12% power (max 20), 1500 mm/s, 50 kHz, 0.045 mm line interval, 1 pass. <span style="color:#9a9aa2">(12% · 1500 mm/s · 50 kHz · 1 pass)</span></p><nav><a href="/settings/stainless-steel/">Stainless Steel</a> <a href="/settings/aluminum/">Aluminum</a> <a href="/settings/titanium/">Titanium</a> <a href="/settings/brass/">Brass</a> <a href="/settings/copper/">Copper</a> <a href="/settings/zinc/">Zinc</a> <a href="/settings/tin-pewter/">Tin &amp; Pewter</a> <a href="/settings/gold/">Gold</a> <a href="/settings/silver/">Silver</a></nav><p style="margin:8px 0 0"><a href="/settings/"><b>Full settings index →</b></a> · <a href="/projects/"><b>Projects →</b></a> · <a href="/tools/"><b>Tools →</b></a> · <a href="/settings/index.json">JSON</a> · <a href="/settings/settings.csv">CSV</a> · <span style="color:#9a9aa2">by <a href="https://hankelsner.com/">Henry Elsner (Hank) III</a></span></p></div></section>
<!-- lv-settings-index:end -->
<!-- lv-projectaddons:begin (compact card; full content at /projectaddons/; /workup/ kept) -->
<style>
#lv-projectaddons{flex:none;padding:18px 16px 22px;border-top:1px solid #26262c;background:#0b0b0e;color:#f2f2f0;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}
#lv-projectaddons .in{max-width:980px;margin:0 auto}
#lv-projectaddons h2{font-size:18px;margin:0 0 6px}
#lv-projectaddons a{color:#7cc4ff}
#lv-projectaddons .mut{color:#9a9aa2}
#lv-projectaddons .refs a{margin-right:12px;white-space:nowrap}
</style>
<section id="lv-projectaddons" aria-label="Laser project add-ons">
  <div class="in">
    <h2>Project add-ons — motorized Z, camera, Pi + Pico</h2>
    <p>Custom build workup moved off the live view: Raspberry Pi + Pico control, overhead camera, Z-axis stepper, BOM, price list, wiring and schematics.</p>
    <p><a href="/projectaddons/"><b>Open /projectaddons →</b></a></p>
    <p class="refs mut">
      <a href="/projectaddons/#bom">BOM</a>
      <a href="/projectaddons/#price">Prices</a>
      <a href="/projectaddons/#wiring">Schematic / wiring</a>
      <a href="/projectaddons/#z">Z-axis</a>
      <a href="/projectaddons/#cam">Camera</a>
      <a href="/projectaddons/#pi">Pi control</a>
      <a href="/workup/">Original /workup/</a>
    </p>
  </div>
</section>
<!-- lv-projectaddons:end -->
<!-- lv-zaxis:begin (2026-10-16, Hank: paragraph + mockup on home, full write-up at /z-axis/) -->
<style>
#lv-zaxis{flex:none;padding:18px 16px 22px;border-top:1px solid #26262c;background:#0b0b0e;color:#f2f2f0;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}
#lv-zaxis .in{max-width:980px;margin:0 auto}
#lv-zaxis h2{font-size:18px;margin:0 0 6px}
#lv-zaxis a{color:#7cc4ff}
#lv-zaxis .mut{color:#9a9aa2}
#lv-zaxis figure{margin:12px 0 6px}
#lv-zaxis figure img{display:block;width:min(720px,100%);height:auto;border:1px solid #26262c;border-radius:12px;background:#0b0b0e}
#lv-zaxis figcaption{color:#9a9aa2;font-size:13px;margin-top:6px}
</style>
<section id="lv-zaxis" aria-label="Pico Z-Axis Control for the ComMarker B4">
  <div class="in">
    <h2>Pico Z-Axis Control for the ComMarker B4 60W MOPA</h2>
    <p>The B4's built-in Z motor, lead screw and top hand knob move the head, but there is no position readout, no go-to height, and no repeatable defocus. This project counts the existing lead screw with a 600&nbsp;PPR quadrature encoder on the screw / top-knob shaft — read by a Raspberry Pi Pico (2,400 counts/rev, position stored in flash, homing to a column sensor) — and drives the machine's own +/− buttons through isolated relays, so the original buttons and knob keep working. Caliper a part, enter its height, and the head goes to focus height minus part thickness; a preset bank per lens (110 / 200) stores the small defocus offsets MOPA colour marking needs, so colours become repeatable. The screw lead is still <b>unknown and unmeasured</b> — calibration comes first, and the full write-up is honest about what screw-counting can and cannot see.</p>
    <figure><a href="/z-axis/"><img src="/z-axis/img/z-axis-mockup.png" alt="Mockup diagram: ComMarker B4 column with laser head over base plate, top hand knob, 600 PPR rotary encoder on the screw shaft, Raspberry Pi Pico box, OLED showing Z 142.35 mm, home sensor on the column, plus/minus button panel, and a workpiece with caliper-part-enter-height workflow" loading="lazy"></a><figcaption>Concept mockup — encoder on the screw shaft, Pico + OLED readout, home sensor, relay-paralleled +/− buttons. Not a photo; lead unknown until measured.</figcaption></figure>
    <p><a href="/z-axis/"><b>Open the Pico Z-Axis project →</b></a> <span class="mut">· concept, workflow, honest accuracy notes, calibration steps, status and rough BOM (~$65–100)</span></p>
  </div>
</section>
<!-- lv-zaxis:end -->
<!-- lv-calibration:begin (2026-10-31, hand block; links /calibration/ (MeerK40t galvo kit) + /calibrate/ (MOPA colour tool)) -->
<style>
#lv-calibration{flex:none;padding:18px 16px 22px;border-top:1px solid #26262c;background:#0b0b0e;color:#f2f2f0;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}
#lv-calibration .in{max-width:980px;margin:0 auto}
#lv-calibration h2{font-size:18px;margin:0 0 6px}
#lv-calibration a{color:#7cc4ff}
#lv-calibration .mut{color:#9a9aa2}
#lv-calibration .refs a{margin-right:12px;white-space:nowrap;display:inline-block}
</style>
<section id="lv-calibration" aria-label="Calibration">
  <div class="in">
    <h2><a href="/calibration/">Calibration</a></h2>
    <p class="mut">MeerK40t galvo calibration for the ComMarker B4 60W MOPA (JCZ, 110 mm lens) on black paper: lens size, .cor correction table, flip/swap flags, scan analysis and downloadable job files.</p>
    <p><a href="/calibration/"><b>Open /calibration →</b></a></p>
    <p class="refs">
      <a href="/calibration/meerk40t/meerk40t_calibration_kit.zip">Calibration kit (.zip)</a>
      <a href="/calibration/meerk40t/README.md">README</a>
      <a href="/calibrate/">MOPA colour calibration tool</a>
    </p>
  </div>
</section>
<!-- lv-calibration:end -->
<!-- lv-rules-tree:begin (managed by laser-rules-build (/opt/laser-rules/build.py)) -->
<style>#lv-rules-tree{flex:none;padding:18px 16px 28px;border-top:1px solid #26262c;background:#0b0b0e;color:#f2f2f0;font:15px/1.5 ui-sans-serif,system-ui,sans-serif}#lv-rules-tree .in{max-width:980px;margin:0 auto}#lv-rules-tree h2{font-size:18px;margin:0 0 8px}#lv-rules-tree h3{font-size:16px;margin:16px 0 6px}#lv-rules-tree a{color:#7cc4ff}#lv-rules-tree ul,#lv-rules-tree ol{margin:6px 0 10px;padding-left:1.2em}#lv-rules-tree li{margin:2px 0}#lv-rules-tree .mut{color:#9a9aa2;font-size:13px}#lv-rules-tree .handbook{margin:0 0 12px;padding:10px 12px;border:1px solid #3a5a7a;border-radius:8px;background:#121820}#lv-rules-tree .handbook b{color:#f2f2f0}#lv-rules-tree .lv-mopa-color{margin-top:22px;padding-top:16px;border-top:1px solid #26262c}#lv-rules-tree .lv-mopa-color h1{font-size:18px;margin:0 0 8px}#lv-rules-tree .lv-mopa-color p,#lv-rules-tree .lv-mopa-color li{color:#dcdbd5}#lv-rules-tree .lv-mopa-color code{background:#17171d;border:1px solid #2a2a31;border-radius:5px;padding:1px 5px;font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}</style>
<section id="lv-rules-tree" aria-label="Laser Memory / Rules"><div class="in"><h2>Laser Memory / Rules</h2><p class="mut">Public lessons for the ComMarker B4 60W MOPA. <a href="/rules/">Overview</a> · <a href="/lessons">All lessons</a> · <a href="/lessons.md">lessons.md</a> · <a href="/rules/index.json">index.json</a></p>
<p class="handbook"><b>Laser Bot Operator Handbook (read first)</b> — <a href="/rules/bot-context/">Laser Bot Operator Handbook (read first)</a> · <a href="/rules/bot-context.md">raw .md</a></p>
<ul>
<li><b><a href="/rules/bot-context/">Laser Bot Operator Handbook (read first)</a></b></li>
<li><a href="/rules/">Overview</a></li>
<li><a href="/rules/api/">Colour plates JSON API (v3)</a></li>
<li><a href="/rules/color/">Color</a></li>
<li><a href="/rules/machine/">Machine</a></li>
<li><a href="/rules/tools/">Tools / converters</a></li>
<li><a href="/rules/workflow/">Workflow</a></li>
<li><b>design/</b><ul>
<li><a href="/rules/design/coins/">Coins / emblems (get the negative right)</a></li>
</ul></li>
<li><b>materials/</b><ul>
<li><a href="/rules/materials/anodized-aluminum/">Anodized aluminum</a></li>
<li><a href="/rules/materials/brass-copper/">Brass / copper deep engraving</a></li>
<li><a href="/rules/materials/color/">MOPA colour on polished stainless: notes and RGB test plan (2026-10-15)</a></li>
<li><a href="/rules/materials/stainless-plain/">Plain stainless</a></li>
<li><a href="/rules/materials/stainless-polished/">Polished stainless</a></li>
</ul></li>
</ul>
<div class="lv-mopa-color" id="mopa-colour-notes">
<p class="mut">Start with the <a href="/rules/bot-context/">Laser Bot Operator Handbook (read first)</a> · MOPA colour knowledge (full notes) · <a href="/rules/materials/color/">HTML</a> · <a href="/rules/materials/color.md">raw .md</a></p>
<h2>MOPA colour on polished stainless: notes and RGB test plan (2026-10-15)</h2>
<p>Machine: ComMarker B4 60 W MOPA, 110 mm lens, LightBurn 1.7. Material: 24.5 mm polished stainless coins.</p>
<h2>Colour model</h2>
<p>Oxide-film interference; thickness sets colour (straw/gold, brown, purple, blue, then 2nd order pale gold, red/magenta, green, then grey/black). Main control: energy density E = W/(speed x interval) J/mm², W = power% x 60 W; crosshatch and passes multiply dose. Pulse width and frequency set pulse energy (W/f) and peak power; longer pulses run warmer. High pulse energy or slow high E causes sparks/burning. Large fills run hotter than small test cells.</p>
<h2>Verified by eye</h2>
<ul>
<li>Purple R1-0106: 15%, 567 mm/s, 280 kHz, 80 ns, 0.004, crosshatch x2 (E≈3.97)</li>
<li>Deep blue R1-0805: 7.5%, 700, 119 kHz, 60 ns, 0.005, x2 (E≈1.29)</li>
<li>Blue R1-0904: 15%, 1200, 230 kHz, 60 ns, 0.005, x2 (E≈1.50)</li>
<li>HD fill black: 14 purple-setting layers at rotating angles + 2 blue facet layers; glossy black, ~130 min per coin.</li>
</ul>
<h2>Lessons 2026-10-15</h2>
<ul>
<li>Hard black (20%, 567 mm/s) sparked and burned on a colour-field coin. For black fields use 4 passes of the HD-black purple setting at 0/45/90/135 (~14 min per coin).</li>
<li>Gold R1-1202 was output-off in that burn and never fired, so gold is <strong>UNTESTED</strong> (it did not fail to show).</li>
</ul>
<h2>Verified by Hank 2026-10-15 on large field wedges (24.5mm polished SS)</h2>
<ul>
<li>Lavender/light purple: 15%, 567 mm/s, 280 kHz, 80 ns, 0.004, crosshatch x2</li>
<li>Slate blue-grey: 7.5%, 700 mm/s, 119 kHz, 60 ns, 0.005, x2</li>
<li>Pale grey-green/silver: 15%, 1200 mm/s, 230 kHz, 60 ns, 0.005, x2</li>
<li>Brown-black with sparks/burned edges: 20%, 567, 280 kHz, 80 ns, 0.004 x2, and 30%, 300, 50 kHz, 200 ns, 0.01 x2. Avoid these on fields.</li>
</ul>
<p>Note: big areas came out much paler than the small test cells (large fills run hotter). The purple vs slate-blue mapping could be swapped (medium-low confidence).</p>
<h2>Published starting points (other machines; scale before use)</h2>
<ul>
<li>Red: 45%, 1000 mm/s, 60 ns, 400 kHz, 0.02 (30 W JPT, 110 mm) hobbyistlaser.com/blog/color-marking-stainless-mopa-settings</li>
<li>Gold: 35%, 1000, 80 ns, 800 kHz, 0.02 (same)</li>
<li>Blue: 40%, 1500, 100 ns, 600 kHz, 0.02 (same)</li>
<li>Red: 27%, 800, 300 kHz, 60 ns, 0.003 (60 W JPT, 210 mm) forum.lightburnsoftware.com/t/experiences-with-color-marking/125488</li>
<li>Green: 200 ns, 70 kHz, 25%, 600 mm/s (60 W JPT) anypowerrun.com/post/detail/1313/</li>
<li>Gold bands 140-200 ns 200-260 kHz; green 420-500 ns 40-60 kHz (20 W) stylecnc.com/blog/mopa-color-laser-engraving-settings-stainless-steel.html</li>
<li>Line interval matters more than speed/power; red and green are hardest on 110 mm: forum.lightburnsoftware.com/t/mopa-colours-hunting-for-a-good-red-and-green-on-brushed-stainless/136015</li>
<li>Power % scales with wattage; ns, kHz and speed don't: mopacolorstudio.com/utilities/mopa-color-reference-chart.html</li>
</ul>
<p>Scaling: halve power % of a 30 W recipe for 60 W; 110 mm lens has a smaller spot, so start about x0.8 and sweep.</p>
<h2>RGB calibration test (8 faces = 4 coins front and back, 26 labelled 2.2 mm cells each, power 25% or less)</h2>
<p>F1 E sweep 0.5-16, 80 ns, 1 pass. F2 same, 2 passes, E 0.5-8. F3 E 0.5-16, 60 ns, 230 kHz. F4 forum-red geometry, E 2.5-16, 1 and 2 passes. F5 pulse width 2-200 ns. F6 frequency 30-800 kHz at 60/200 ns. F7 200/350/500 ns warm/gold plus published gold and green. F8 verified colours at x0.7/1/1.4/2 plus published reds. About 21 min total. Results get photographed under fixed white light and fitted to colour = f(dose, ns, f).</p>
</div>
</div></section>
<!-- lv-rules-tree:end -->


<!-- adman:begin · ad slot managed by adman (ads.hankelsner.tech). Edits inside are overwritten; opt a site out in the manager --><aside class="adman-slot" aria-label="Advertisement" style="display:block;box-sizing:border-box;width:100%;max-width:min(960px,100%);min-width:0;margin:32px auto;padding:0 16px;clear:both;flex:0 0 auto;grid-column:1/-1"><iframe src="https://ads.hankelsner.tech/slot.html#h=laserview.hankelsner.tech" title="Advertisement" loading="lazy" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" style="display:block;width:100%;height:160px;border:0;border-radius:14px;background:transparent;color-scheme:normal"></iframe></aside><style>@media (max-width:600px){.adman-slot iframe{height:220px!important}}</style><!-- adman:end -->
</body>
</html>
