← Commit history
SKILL.mdadded+39
@@ -0,0 +1,39 @@+---+name: mjpeg-stream-viewer+description: Show a raw MJPEG camera stream (multipart/x-mixed-replace, bench cams, printer cams, camera boxes) in a Hydrogen pane that fits, proxied through the container so an http LAN camera shows inside https Hydrogen, with the frame rate capped and a snapshot button. Use when a camera stream URL opens at native size with scrollbars, will not load in Hydrogen because it is http, or floods the browser at 4K 30 fps. Trigger words: mjpeg, camera stream, stream does not fit, show my camera, bench cam, multipart x-mixed-replace, snapshot the camera, camera in a pane, mjpeg-stream-viewer.+---++# MJPEG Stream Viewer++Paste a camera stream URL, get a pane that fits. The server proxies the stream through the container, keeps the aspect, caps the frame rate, and saves snapshots.++## Show a camera++```bash+mjpeg-stream-viewer --ai-thread "<your thread name>" show --url http://192.168.0.34:3000/stream [--fps 5] [--name "Bench cam"]+```++`show` reuses this thread's running instance or starts one (`--port auto`), then opens the page in a Hydrogen webview tab in a work pane. The page takes `?url=`, `?fps=` and `?bare=1`. `--bare` opens it with nothing but the picture (the header and bar hide; hover the corner button or press `b` to bring them back); use it when the user wants "just the raw image". Other verbs: `serve [--port N|auto] [--url <cam>] [--print-url] [--ttl H] [--keep-alive]`, `ls`, `state`, `stop`. The global `--ai-thread "<name>"` flag comes first on every state-changing verb; without it the call is refused with `caller_identity_required`.++## Displaying this app (REQUIRED — read before showing anything)++To SHOW this app, use `mjpeg-stream-viewer show` (it opens the proper Hydrogen webview tab itself). For manual display, FIRST load the `webview` skill (adom-workspace-control) and follow it.+- **NEVER open this app in VS Code's "Simple Browser"** — not a webview in the Adom ecosystem: no live channel, no console read-back, no AI drivability. Using it is a bug.+- Surfaces: **wv** — Hydrogen webview panel (`webview` skill; Hydrogen's pane tab inside Hydrogen), **pup** (`pup` skill), the user's **real signed-in browser** (Adom browser extension), and **hdbw** — Hydrogen's standalone Tauri browser window (only under Hydrogen). Simple Browser is NEVER one of them.++## What to expect and how to read it++- Status pills: `source` (grey no source, yellow connecting, green frames flowing, red error with the reason), `frames` (fps and KB/s after the cap), `size` (the JPEG's own dimensions; a hint when the source is bigger than the pane can use). `GET /api/status` returns the same for the dock.+- Fit keeps the whole frame letterboxed; Fill crops to the pane. Snapshot saves `~/project/screenshots/mjpeg-<timestamp>.jpg` and tells you the path; Read the file to see it.+- Settings (gear): max fps (default 10), default view, idle shutdown hours, saved sources. They persist on the server for every tab.++## Gotchas++- The camera must be reachable from the container. A cloud container on another network shows a red `source` pill with the connection error; the desktop and an office container see the LAN.+- Only http and https sources are proxied. The viewer refuses its own URL.+- 4K at 30 fps is about 11 MB/s raw. Cap it (5 fps is fine for watching a print) and, if the box can, ask it for a 1080p stream.+- Do not point a bare webview at the raw stream: that is the native-size-with-scrollbars problem this app exists for.++## In a shared workspace or the dock++`dockbar.json` ships with the page, so the dock launches it and `adom-cli hydrogen workspace apply` can open a camera pane from a bench: `{ "panel": "app", "app": "adom/mjpeg-stream-viewer", "args": { "url": "<stream>", "fps": "5" } }`.
bin/mjpeg-stream-vieweradded+5
@@ -0,0 +1,5 @@+#!/bin/sh+# Launcher: resolves the package dir through the ~/.local/bin symlink.+SELF="$(readlink -f "$0")"+DIR="$(cd "$(dirname "$SELF")/.." && pwd)"+exec node "$DIR/server.mjs" "$@"
dockbar.jsonadded+17
@@ -0,0 +1,17 @@+{+  "dock_manifest": "1.2.0",+  "say": ["show my camera stream", "open the bench cam in a pane", "take a snapshot of the camera"],+  "webview": { "title": "MJPEG Stream Viewer", "favicon": "public/icon.svg" },+  "health": { "url": "/api/status", "poll_seconds": 15, "timeout_ms": 2000 },+  "launch": {+    "mode": "container_command",+    "console": "show",+    "steps": [+      { "label": "Installing or updating the MJPEG stream viewer", "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update mjpeg-stream-viewer || adom-wiki pkg install adom/mjpeg-stream-viewer'", "timeout_seconds": 120 },+      { "label": "Starting the viewer", "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec mjpeg-stream-viewer --ai-thread \"${ADOM_AI_THREAD:-dock}\" serve --print-url --port auto'", "await": "url", "timeout_seconds": 60 }+    ],+    "on_ready": "navigate_placeholder"+  },+  "context": { "query": ["url", "fps"], "env": "ADOM_CONTEXT" },+  "requires": { "desktop": false, "bridges": [] }+}
install.shadded+7
@@ -0,0 +1,7 @@+#!/bin/sh+set -e+mkdir -p "$HOME/.local/bin"+chmod +x "$PWD/bin/mjpeg-stream-viewer"+ln -sfn "$PWD/bin/mjpeg-stream-viewer" "$HOME/.local/bin/mjpeg-stream-viewer"+echo "installed: mjpeg-stream-viewer"+echo "try: mjpeg-stream-viewer --ai-thread \"<your thread>\" show --url http://<camera>/stream"
package.jsonadded+66
@@ -0,0 +1,66 @@+{+  "name": "mjpeg-stream-viewer",+  "slug": "mjpeg-stream-viewer",+  "version": "0.1.0",+  "type": "app",+  "title": "MJPEG Stream Viewer",+  "brief": "Give it a camera URL and the raw MJPEG stream turns into a pane that fits: aspect kept, frame rate capped, one-click snapshot.",+  "description": "A raw MJPEG stream (multipart/x-mixed-replace, what bench cams, printer cams and camera boxes emit) opened straight in a Hydrogen webview paints at its native size with scrollbars. This viewer proxies the stream through the container so an http camera on the LAN shows inside https Hydrogen, fits the frame to the pane with the aspect kept, caps the frame rate so a 4K 30 fps source does not flood the browser, shows fps and bandwidth, and saves a snapshot to the project. Ships a dock manifest and takes ?url= so a shared Hydrogen workspace can open a camera in a pane.",+  "tags": [+    "camera",+    "mjpeg",+    "stream",+    "viewer",+    "webview",+    "bench-cam",+    "dock"+  ],+  "discovery_triggers": [+    "mjpeg stream viewer",+    "camera stream does not fit",+    "show my camera in hydrogen",+    "multipart/x-mixed-replace",+    "bench cam webview",+    "view mjpeg url"+  ],+  "discovery_pitch": "Paste a camera stream URL and see it fit in a Hydrogen pane: proxied through the container, aspect kept, frame rate capped, snapshot button.",+  "sample_prompts": [+    {+      "label": "Show a camera",+      "prompt": "Show http://192.168.0.34:3000/stream in a Hydrogen pane with the mjpeg stream viewer"+    },+    {+      "label": "Cap the rate",+      "prompt": "Open my bench cam in the mjpeg viewer at 5 fps and take a snapshot"+    }+  ],+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "needs_sudo": false,+  "hero": {+    "type": "image",+    "path": "screenshots/hero.png",+    "headline": "MJPEG Stream Viewer",+    "subhead": "Turn raw camera streams into a panel"+  },+  "files": [+    "package.json",+    "README.md",+    "SKILL.md",+    "install.sh",+    "uninstall.sh",+    "server.mjs",+    "bin/mjpeg-stream-viewer",+    "public/**",+    "dockbar.json",+    "screenshots/*.png"+  ],+  "author": {+    "name": "Colby Knox",+    "email": "[email protected]"+  },+  "license": "CC-BY-SA-4.0"+}
public/icon.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="13" height="10" rx="2"/><path d="M16 10.5l5-2.5v8l-5-2.5"/><circle cx="9.5" cy="12" r="2.3"/></svg>
public/index.htmladded+121
@@ -0,0 +1,121 @@+<!doctype html>+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">+<title>MJPEG Stream Viewer</title>+<link rel="icon" href="icon.svg">+<style>+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+:root{--bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--line:#30363d;--text:#e6edf3;--text2:#8b949e;--text3:#484f58;--accent:#00b8b0;--accent-bright:#00e6dc;--ok:#66bb6a;--warn:#d29922;--bad:#ef5350}+*{box-sizing:border-box}html,body{height:100%;margin:0}+body{background:var(--bg);color:var(--text);font:14px/1.4 'Satoshi',system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden}+header{height:44px;flex:0 0 44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--line)}+.brand{display:flex;align-items:center;gap:10px;min-width:0;position:relative}+.brand svg{width:18px;height:18px;flex:none}+.brand .name{font:700 15px 'Familjen Grotesk',sans-serif;white-space:nowrap}+.brand .sub{color:var(--text2);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.brand .pop{display:none;position:absolute;top:34px;left:0;background:var(--overlay);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:12px;color:var(--text2);white-space:nowrap;z-index:5}+.brand:hover .pop{display:block}+.chip{font:12px 'JetBrains Mono',ui-monospace,monospace;color:var(--text2);border:1px solid var(--line);border-radius:999px;padding:2px 10px;display:flex;align-items:center;gap:6px;white-space:nowrap}+.led{width:8px;height:8px;border-radius:50%;background:var(--text3);flex:none}+.led.green{background:var(--ok)}.led.yellow{background:var(--warn)}.led.red{background:var(--bad)}+.spacer{flex:1}+.iconbtn{width:30px;height:30px;border:0;background:transparent;color:var(--text2);border-radius:6px;cursor:pointer;display:grid;place-items:center}+.iconbtn:hover{background:var(--elevated);color:var(--text)}+.bar{display:flex;align-items:center;gap:8px;padding:8px 16px;background:var(--surface);border-bottom:1px solid var(--line);flex-wrap:wrap}+.bar input[type=url]{flex:1 1 320px;min-width:200px;height:32px;padding:0 10px;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--text);font:13px 'JetBrains Mono',ui-monospace,monospace}+.bar select{height:32px;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--text);font:13px 'Satoshi',sans-serif;padding:0 8px;max-width:220px}+.btn{height:32px;padding:0 14px;border-radius:6px;border:1px solid rgba(0,184,176,.35);background:transparent;color:var(--accent);font:500 13px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s,color .15s}+.btn:hover{background:rgba(0,184,176,.08)}.btn.primary{background:var(--accent);color:#0d1117;border-color:transparent}.btn.primary:hover{background:var(--accent-bright)}+.pills{display:flex;gap:8px;flex-wrap:wrap}+.view{flex:1;position:relative;background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden}+.view img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;display:block}+.view.cover img{width:100%;height:100%;object-fit:cover}+.empty{color:var(--text2);text-align:center;max-width:520px;padding:24px}+.empty code{color:var(--text);font:12px 'JetBrains Mono',ui-monospace,monospace}+.toast{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);background:rgba(22,27,34,.92);backdrop-filter:blur(16px);border:1px solid rgba(0,184,177,.2);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:0 12px 40px rgba(0,0,0,.55);display:none;z-index:6}+.modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:7}+.modal .panel{width:min(520px,92vw);background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:18px 20px;box-shadow:0 12px 40px rgba(0,0,0,.55)}+.modal h2{margin:0 0 12px;font:700 16px 'Familjen Grotesk',sans-serif}+.row{display:flex;align-items:center;gap:10px;margin:8px 0}.row label{flex:0 0 150px;color:var(--text2);font-size:13px}+.row input,.row select{flex:1;height:30px;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--text);padding:0 8px;font:13px 'Satoshi',sans-serif}+.help{color:var(--text3);font-size:12px;margin:2px 0 10px 160px}+.hint{color:var(--text2);font-size:12px;margin-top:10px}+/* bare mode: nothing but the picture; a corner button brings the chrome back */+body.bare header,body.bare .bar{display:none}+.chromebtn{position:fixed;top:8px;right:8px;z-index:4;width:30px;height:30px;border-radius:6px;border:1px solid rgba(230,237,243,.15);background:rgba(22,27,34,.55);backdrop-filter:blur(8px);color:var(--text2);display:grid;place-items:center;cursor:pointer;opacity:0;transition:opacity .15s}+body:hover .chromebtn,.chromebtn:focus{opacity:1}+body:not(.bare) .chromebtn{top:52px}+</style></head><body>+<header>+  <div class="brand">+    <svg viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="13" height="10" rx="2"/><path d="M16 10.5l5-2.5v8l-5-2.5"/><circle cx="9.5" cy="12" r="2.3"/></svg>+    <span class="name">MJPEG Stream Viewer</span><span class="sub">give it a URL, it fits</span>+    <div class="pop">version <span id="ver">?</span> · <a href="https://wiki.adom.inc/adom/mjpeg-stream-viewer" target="_blank" style="color:var(--accent)">wiki page</a></div>+  </div>+  <span class="chip"><span class="led green"></span>target: this machine</span>+  <span class="spacer"></span>+  <button class="iconbtn" id="gear" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button>+</header>+<div class="bar">+  <input type="url" id="url" placeholder="http://192.168.0.34:3000/stream" spellcheck="false">+  <select id="sources" title="Saved sources"><option value="">saved sources</option></select>+  <button class="btn primary" id="go">Show</button>+  <button class="btn" id="fit" title="Fit keeps the whole frame; Fill crops to the pane">Fit</button>+  <button class="btn" id="snap">Snapshot</button>+  <button class="btn" id="save">Save source</button>+  <span class="pills" id="pills"></span>+</div>+<div class="view" id="view"><div class="empty" id="empty">Paste a camera stream URL above and press Show, or open this page with <code>?url=&lt;stream&gt;</code>. The stream is proxied through this container, so an http camera on the LAN shows inside https Hydrogen; frames are capped at the rate in Settings.</div></div>+<button class="chromebtn" id="chrome" title="Show or hide the controls (also ?bare=1)" aria-label="Toggle controls"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4"/></svg></button>+<div class="toast" id="toast"></div>+<div class="modal" id="modal"><div class="panel">+  <h2>Settings</h2>+  <div class="row"><label for="s-fps">Max frames per second</label><input id="s-fps" type="number" min="1" max="30"></div><div class="help">The proxy drops frames above this. 10 is plenty for a bench cam; 4K at 30 fps is about 11 MB/s.</div>+  <div class="row"><label for="s-fit">Default view</label><select id="s-fit"><option value="contain">Fit (whole frame)</option><option value="cover">Fill (crop to pane)</option></select></div>+  <div class="row"><label for="s-ttl">Idle shutdown (hours)</label><input id="s-ttl" type="number" min="1" max="720"></div><div class="help">The server stops itself after this long without a request. Restart with the CLI or the dock.</div>+  <div class="row"><label>Saved sources</label><span id="s-count" style="color:var(--text2);font-size:13px"></span></div>+  <div class="hint">Settings persist on the server for every tab. This app was started by ai-thread <code id="s-thread">?</code>.</div>+  <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px"><button class="btn" id="s-cancel">Close</button><button class="btn primary" id="s-save">Save</button></div>+</div></div>+<script>+(function(){+  var qs=new URLSearchParams(location.search);+  var AI=qs.get('ai_thread')||'viewer-page';+  var $=function(id){return document.getElementById(id)};+  var img=null, settings={maxFps:10,fit:'contain',sources:[]}, current='';+  function toast(t){var e=$('toast');e.textContent=t;e.style.display='block';clearTimeout(e._t);e._t=setTimeout(function(){e.style.display='none'},3500)}+  function api(p,body){return fetch(p,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({aiThread:AI},body))}:undefined).then(function(r){return r.json()})}+  function show(u,fps){+    if(!u){return}+    current=u; $('url').value=u;+    var v=$('view'); $('empty').style.display='none';+    if(img){img.src='';img.remove()}+    img=new Image(); img.alt='camera stream'; img.onerror=function(){toast('the stream did not load; check the URL and that this container can reach it')};+    img.src='stream?url='+encodeURIComponent(u)+(fps?'&fps='+encodeURIComponent(fps):'')+'&t='+Date.now();+    v.appendChild(img);+    try{localStorage.setItem('mjpeg.last',u)}catch(e){}+    var h=location.pathname+'?url='+encodeURIComponent(u)+(document.body.classList.contains('bare')?'&bare=1':''); if(history.state!==h) history.replaceState(h,'',h);+  }+  function setBare(on){document.body.classList.toggle('bare',!!on);try{localStorage.setItem('mjpeg.bare',on?'1':'0')}catch(e){}}+  $('chrome').onclick=function(){setBare(!document.body.classList.contains('bare'))};+  document.addEventListener('keydown',function(e){if(e.key==='b'&&!/input|select|textarea/i.test(e.target.tagName))$('chrome').click()});+  setBare(qs.get('bare')==='1'||(qs.get('bare')!=='0'&&(function(){try{return localStorage.getItem('mjpeg.bare')==='1'}catch(e){return false}})()));+  function setFit(mode){settings.fit=mode;$('view').classList.toggle('cover',mode==='cover');$('fit').textContent=mode==='cover'?'Fill':'Fit'}+  function renderSources(){var s=$('sources');s.innerHTML='<option value="">saved sources</option>';(settings.sources||[]).forEach(function(x){var o=document.createElement('option');o.value=x.url;o.textContent=x.name||x.url;s.appendChild(o)});$('s-count').textContent=(settings.sources||[]).length+' saved (use Save source in the bar)'}+  function pill(cls,text,title){return '<span class="chip" title="'+(title||'').replace(/"/g,'&quot;')+'"><span class="led '+cls+'"></span>'+text+'</span>'}+  function poll(){fetch('api/status').then(function(r){return r.json()}).then(function(s){var l=s.leds||{};$('pills').innerHTML=Object.keys(l).map(function(k){return pill(l[k].state,l[k].label,l[k]._hint)}).join('');$('ver').textContent=s.version||'?';$('s-thread').textContent=s.aiThread||'?'}).catch(function(){})}+  fetch('settings').then(function(r){return r.json()}).then(function(s){settings=s;setFit(s.fit||'contain');renderSources();var u=qs.get('url')||(function(){try{return localStorage.getItem('mjpeg.last')}catch(e){return ''}})();if(u)show(u,qs.get('fps'))});+  $('go').onclick=function(){show($('url').value.trim(),qs.get('fps'))};+  $('url').addEventListener('keydown',function(e){if(e.key==='Enter')$('go').click()});+  $('sources').onchange=function(){if(this.value)show(this.value)};+  $('fit').onclick=function(){setFit(settings.fit==='cover'?'contain':'cover')};+  $('snap').onclick=function(){if(!current)return toast('show a stream first');api('api/snapshot',{url:current}).then(function(r){toast(r.ok?'saved '+r.file.replace(/^.*\//,'')+' in ~/project/screenshots':'snapshot failed: '+(r.error||'')+' '+(r.hint||''))})};+  $('save').onclick=function(){if(!current)return toast('show a stream first');var name=prompt('Name for this source',current.replace(/^https?:\/\//,''));if(name===null)return;var list=(settings.sources||[]).filter(function(x){return x.url!==current});list.push({name:name,url:current});api('settings',{sources:list}).then(function(s){settings=s;renderSources();toast('saved')})};+  $('gear').onclick=function(){$('s-fps').value=settings.maxFps;$('s-fit').value=settings.fit;$('s-ttl').value=settings.ttlHours||24;$('modal').style.display='flex'};+  $('s-cancel').onclick=function(){$('modal').style.display='none'};+  $('s-save').onclick=function(){api('settings',{maxFps:Number($('s-fps').value),fit:$('s-fit').value,ttlHours:Number($('s-ttl').value)}).then(function(s){settings=s;setFit(s.fit);$('modal').style.display='none';toast('settings saved');if(current)show(current)})};+  poll();setInterval(poll,5000);+})();+</script>+</body></html>
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
server.mjsadded+283
@@ -0,0 +1,283 @@+#!/usr/bin/env node+// MJPEG Stream Viewer: a raw multipart/x-mixed-replace camera stream opened+// straight in a Hydrogen webview paints at native size with scrollbars, and+// an http LAN camera cannot show inside https Hydrogen at all. This server+// proxies the stream (same origin, through the container), caps the frame+// rate, and serves a page that fits the picture to the pane.+//+// CLI (global --ai-thread first, like every Adom app):+//   mjpeg-stream-viewer --ai-thread "<name>" serve [--port N|auto] [--url <cam>] [--print-url] [--ttl H] [--keep-alive]+//   mjpeg-stream-viewer --ai-thread "<name>" show --url <cam> [--fps N] [--bare] [--name "<tab>"]+//   mjpeg-stream-viewer ls | state [--port N] | stop [--port N]+import http from "node:http";+import https from "node:https";+import fs from "node:fs";+import path from "node:path";+import os from "node:os";+import { execFile } from "node:child_process";+import { fileURLToPath } from "node:url";++const __dirname = path.dirname(fileURLToPath(import.meta.url));+const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"));+const VERSION = PKG.version;+const APP = "mjpeg-stream-viewer";+const HOME = os.homedir();+const INST_DIR = path.join(HOME, ".adom", "instances", APP);+const SETTINGS_FILE = path.join(INST_DIR, "settings.json");+const DEFAULT_PORT = 8873;++// ---- args -----------------------------------------------------------------+const argv = process.argv.slice(2);+let aiThread = process.env.ADOM_AI_THREAD || "";+if (argv[0] === "--ai-thread") { aiThread = argv[1] || ""; argv.splice(0, 2); }+const verb = argv.shift() || "serve";+const flags = {};+for (let i = 0; i < argv.length; i++) {+  const a = argv[i];+  if (!a.startsWith("--")) continue;+  const k = a.slice(2);+  if (["print-url", "keep-alive", "json", "bare"].includes(k)) { flags[k] = true; continue; }+  flags[k] = argv[i + 1]; i++;+}+const die = (msg, code = 1) => { console.error(msg); process.exit(code); };+const threadSlug = (t) => String(t || "anonymous").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "anonymous";++// ---- settings (server side, per app-creator-settings) -----------------------+const DEFAULTS = { maxFps: 10, fit: "contain", ttlHours: 24, sources: [] };+function loadSettings() {+  try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8")) }; } catch { return { ...DEFAULTS }; }+}+function saveSettings(s) { fs.mkdirSync(INST_DIR, { recursive: true }); fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2)); }++// ---- registry (per app-creator-server-lifecycle) ----------------------------+function regFile(t) { return path.join(INST_DIR, threadSlug(t) + ".json"); }+function readRegistry() {+  try { return fs.readdirSync(INST_DIR).filter(f => f.endsWith(".json") && f !== "settings.json").map(f => { try { return JSON.parse(fs.readFileSync(path.join(INST_DIR, f), "utf8")); } catch { return null; } }).filter(Boolean); } catch { return []; }+}+function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }+function probe(port, ms = 800) {+  return new Promise((resolve) => {+    const req = http.get({ host: "127.0.0.1", port, path: "/version", timeout: ms }, (res) => { let b = ""; res.on("data", d => b += d); res.on("end", () => { try { resolve(JSON.parse(b)); } catch { resolve(null); } }); });+    req.on("error", () => resolve(null)); req.on("timeout", () => { req.destroy(); resolve(null); });+  });+}++// ---- URL policy --------------------------------------------------------------+function checkSourceUrl(u) {+  let url;+  try { url = new URL(String(u || "")); } catch { return { ok: false, reason: "not a URL" }; }+  if (url.protocol !== "http:" && url.protocol !== "https:") return { ok: false, reason: "only http and https sources" };+  return { ok: true, url };+}++// ---- the proxy: forward frames at a capped rate --------------------------------+// Upstream is a multipart stream of JPEG parts. Frames are found by the JPEG+// markers themselves (FFD8 .. FFD9), so it works with or without+// Content-Length headers and with any boundary string. Every forwarded frame is+// re-wrapped with our own boundary so the browser sees a clean stream.+const stats = { source: null, fps: 0, bytesPerSec: 0, width: 0, height: 0, clients: 0, lastFrameAt: 0, lastError: null };+let frameCount = 0, byteCount = 0;+setInterval(() => { stats.fps = frameCount; stats.bytesPerSec = byteCount; frameCount = 0; byteCount = 0; }, 1000).unref();++function jpegSize(buf) {+  let k = 2;+  while (k + 9 < buf.length) {+    if (buf[k] !== 0xFF) { k++; continue; }+    const m = buf[k + 1];+    if (m === 0xC0 || m === 0xC1 || m === 0xC2) return { height: buf.readUInt16BE(k + 5), width: buf.readUInt16BE(k + 7) };+    if (m === 0xD8 || (m >= 0xD0 && m <= 0xD7) || m === 0x01) { k += 2; continue; }+    k += 2 + buf.readUInt16BE(k + 2);+  }+  return null;+}++function proxyStream(sourceUrl, maxFps, res, onlyOne = false) {+  const lib = sourceUrl.protocol === "https:" ? https : http;+  const minGap = maxFps > 0 ? 1000 / maxFps : 0;+  const BOUNDARY = "mjpegviewerframe";+  let up = null, buf = Buffer.alloc(0), lastSent = 0, sentAny = false, closed = false;+  const end = () => { if (closed) return; closed = true; try { up && up.destroy(); } catch {} try { if (!res.writableEnded) res.end(); } catch {} stats.clients = Math.max(0, stats.clients - 1); };+  const req = lib.get(sourceUrl, { headers: { "User-Agent": `${APP}/${VERSION}` }, timeout: 8000 }, (u) => {+    up = u;+    if (u.statusCode !== 200) { stats.lastError = `upstream ${u.statusCode}`; res.writeHead(502, { "Content-Type": "text/plain" }); res.end(`upstream answered ${u.statusCode}`); return end(); }+    const ct = String(u.headers["content-type"] || "");+    stats.lastError = null;+    if (!onlyOne) res.writeHead(200, { "Content-Type": `multipart/x-mixed-replace; boundary=${BOUNDARY}`, "Cache-Control": "no-store", "Connection": "keep-alive" });+    stats.clients++;+    // A plain image (a camera that serves a still) is forwarded once and refreshed by the page.+    if (/^image\/jpeg/i.test(ct) && !/multipart/i.test(ct)) {+      const chunks = []; u.on("data", d => chunks.push(d)); u.on("end", () => { const f = Buffer.concat(chunks); emit(f); end(); }); return;+    }+    u.on("data", (d) => {+      buf = buf.length ? Buffer.concat([buf, d]) : d;+      // pull every complete JPEG out of the buffer+      for (;;) {+        const s = buf.indexOf(Buffer.from([0xFF, 0xD8]));+        if (s < 0) { if (buf.length > 4e6) buf = Buffer.alloc(0); break; }+        const e = buf.indexOf(Buffer.from([0xFF, 0xD9]), s + 2);+        if (e < 0) { if (s > 0) buf = buf.subarray(s); if (buf.length > 12e6) buf = Buffer.alloc(0); break; }+        const frame = buf.subarray(s, e + 2); buf = buf.subarray(e + 2);+        emit(frame);+        if (onlyOne) return end();+      }+    });+    u.on("end", end); u.on("error", (e) => { stats.lastError = e.message; end(); });+  });+  req.on("error", (e) => { stats.lastError = e.message; if (!res.headersSent) { res.writeHead(502, { "Content-Type": "text/plain" }); } res.end(`cannot reach ${sourceUrl.href}: ${e.message}`); end(); });+  req.on("timeout", () => { req.destroy(new Error("timeout")); });+  res.on("close", end);+  function emit(frame) {+    const now = Date.now();+    if (!onlyOne && sentAny && minGap && now - lastSent < minGap) return; // decimate+    lastSent = now; sentAny = true; frameCount++; byteCount += frame.length; stats.lastFrameAt = now;+    const sz = jpegSize(frame); if (sz) { stats.width = sz.width; stats.height = sz.height; }+    if (onlyOne) { res.writeHead(200, { "Content-Type": "image/jpeg", "Content-Length": frame.length, "Cache-Control": "no-store" }); res.end(frame); return; }+    res.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`); res.write(frame); res.write("\r\n");+  }+}++// ---- server ----------------------------------------------------------------+async function serve() {+  const settings = loadSettings();+  const ttlH = Number(flags.ttl || settings.ttlHours || 24);+  const keepAlive = !!flags["keep-alive"];+  const initialUrl = flags.url || null;+  // reuse instead of respawn: this thread's live instance answers already+  for (const r of readRegistry()) {+    if (r.aiThread === aiThread && alive(r.pid) && await probe(r.port)) {+      const out = { ok: true, reused: true, port: r.port, url: `http://127.0.0.1:${r.port}/`, pid: r.pid, version: r.version };+      if (initialUrl) out.url += `?url=${encodeURIComponent(initialUrl)}`;+      if (flags["print-url"]) console.log(out.url); else console.log(JSON.stringify(out, null, 2));+      return;+    }+  }+  let port = flags.port === "auto" ? 0 : Number(flags.port || DEFAULT_PORT);+  const pageFile = path.join(__dirname, "public", "index.html");+  const icon = fs.readFileSync(path.join(__dirname, "public", "icon.svg"));+  let lastActivity = Date.now();+  const json = (res, code, obj) => { res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify(obj)); };+  const readBody = (req) => new Promise((resolve) => { let b = ""; req.on("data", d => { b += d; if (b.length > 1e6) req.destroy(); }); req.on("end", () => { try { resolve(JSON.parse(b || "{}")); } catch { resolve({}); } }); });+  const needThread = (body, res) => {+    if (body && body.aiThread) return true;+    json(res, 400, { ok: false, error: "caller_identity_required", hint: `This call changes state, so it needs the ai-thread name. Re-run with: ${APP} --ai-thread "<name>" ...  (raw callers: add "aiThread" to the JSON body).` });+    return false;+  };+  const server = http.createServer(async (req, res) => {+    lastActivity = Date.now();+    const url = new URL(req.url, "http://x");+    const p = url.pathname.replace(/\/+$/, "") || "/";+    if (p === "/" || p === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); return res.end(fs.readFileSync(pageFile)); }+    if (p === "/icon.svg") { res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" }); return res.end(icon); }+    if (p === "/version" || p === "/health") return json(res, 200, { ok: true, app: APP, version: VERSION, port, aiThread, pid: process.pid });+    if (p === "/settings" && req.method === "GET") return json(res, 200, { ...loadSettings(), _enums: { fit: ["contain", "cover"] }, _ranges: { maxFps: [1, 30], ttlHours: [1, 720] } });+    if (p === "/settings" && req.method === "POST") {+      const body = await readBody(req); if (!needThread(body, res)) return;+      const s = loadSettings();+      if (body.maxFps !== undefined) s.maxFps = Math.min(30, Math.max(1, Number(body.maxFps) || 10));+      if (body.fit !== undefined) s.fit = body.fit === "cover" ? "cover" : "contain";+      if (body.ttlHours !== undefined) s.ttlHours = Math.min(720, Math.max(1, Number(body.ttlHours) || 24));+      if (Array.isArray(body.sources)) s.sources = body.sources.filter(x => x && typeof x.url === "string" && checkSourceUrl(x.url).ok).map(x => ({ name: String(x.name || x.url).slice(0, 60), url: x.url })).slice(0, 32);+      saveSettings(s); return json(res, 200, { ok: true, ...s });+    }+    if (p === "/api/status") {+      const s = loadSettings();+      const fresh = stats.lastFrameAt && Date.now() - stats.lastFrameAt < 5000;+      return json(res, 200, {+        ok: true, version: VERSION, aiThread, state: stats.lastError ? "error" : (fresh ? "ok" : (stats.clients ? "connecting" : "idle")),+        leds: {+          source: { state: stats.source ? (stats.lastError ? "red" : (fresh ? "green" : "yellow")) : "grey", label: stats.source || "no source", _hint: stats.lastError || (stats.source ? "frames flowing" : "open ?url=<camera stream> or pick a saved source") },+          frames: { state: fresh ? "green" : "grey", label: `${stats.fps} fps, ${(stats.bytesPerSec / 1024).toFixed(0)} KB/s`, _hint: `capped at ${s.maxFps} fps by the proxy; raise or lower it in Settings` },+          size: { state: stats.width ? "green" : "grey", label: stats.width ? `${stats.width}x${stats.height}` : "no frame yet", _hint: stats.width > 1920 ? "a 4K source costs bandwidth for a pane this size; a 1080p source would look the same here" : "source frame size" },+        },+        stats: { ...stats }, settings: { maxFps: s.maxFps, fit: s.fit },+      });+    }+    if (p === "/stream" || p === "/snapshot.jpg") {+      const chk = checkSourceUrl(url.searchParams.get("url"));+      if (!chk.ok) { res.writeHead(400, { "Content-Type": "text/plain" }); return res.end(`bad url: ${chk.reason}`); }+      if (chk.url.hostname === "127.0.0.1" && Number(chk.url.port) === port) { res.writeHead(400, { "Content-Type": "text/plain" }); return res.end("that is this viewer"); }+      stats.source = chk.url.href;+      const fps = Math.min(30, Math.max(0, Number(url.searchParams.get("fps") || loadSettings().maxFps || 10)));+      return proxyStream(chk.url, fps, res, p === "/snapshot.jpg");+    }+    if (p === "/api/snapshot" && req.method === "POST") {+      const body = await readBody(req); if (!needThread(body, res)) return;+      const chk = checkSourceUrl(body.url); if (!chk.ok) return json(res, 400, { ok: false, error: chk.reason });+      const dir = path.join(HOME, "project", "screenshots"); fs.mkdirSync(dir, { recursive: true });+      const file = path.join(dir, `mjpeg-${new Date().toISOString().replace(/[:.]/g, "-")}.jpg`);+      const chunks = [];+      // A stand-in response that collects one frame; end() answers exactly+      // once (the proxy calls end on both the frame and the upstream close).+      let answered = false;+      const fake = { headersSent: false, writeHead() {}, write(d) { chunks.push(Buffer.from(d)); }, on() {},+        end(d) {+          if (answered) return; answered = true;+          if (d) chunks.push(Buffer.from(d)); const b = Buffer.concat(chunks);+          if (b.length > 100) { fs.writeFileSync(file, b); json(res, 200, { ok: true, file, bytes: b.length, hint: "Read the file to view it; it is under ~/project/screenshots so VS Code shows it too." }); }+          else json(res, 502, { ok: false, error: "no frame received", hint: stats.lastError || "is the camera reachable from this container?" });+        } };+      return proxyStream(chk.url, 0, fake, true);+    }+    if (p === "/shutdown" && req.method === "POST") {+      const body = await readBody(req); if (!needThread(body, res)) return;+      json(res, 200, { ok: true, bye: true }); setTimeout(() => { deregister(); process.exit(0); }, 100); return;+    }+    res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found");+  });+  const deregister = () => { try { fs.unlinkSync(regFile(aiThread)); } catch {} };+  server.listen(port, "127.0.0.1", () => {+    port = server.address().port;+    fs.mkdirSync(INST_DIR, { recursive: true });+    fs.writeFileSync(regFile(aiThread), JSON.stringify({ app: APP, aiThread, port, pid: process.pid, version: VERSION, startedAt: new Date().toISOString(), cwd: process.cwd() }, null, 2));+    let url = `http://127.0.0.1:${port}/`; if (initialUrl) url += `?url=${encodeURIComponent(initialUrl)}`;+    if (flags["print-url"]) console.log(url); else console.log(JSON.stringify({ ok: true, port, url, pid: process.pid, aiThread, ttlHours: keepAlive ? null : ttlH }, null, 2));+    // the dock pre-opens a tab named by webview.title; claim it once we are up+    if (process.env.ADOM_DOCK_WEBVIEW) execFile("adom-cli", ["hydrogen", "webview", "open-or-refresh", "--name", process.env.ADOM_DOCK_WEBVIEW, "--url", url], () => {});+    if (!keepAlive) setInterval(() => { if (Date.now() - lastActivity > ttlH * 3600e3) { deregister(); process.exit(0); } }, 60e3).unref();+  });+  process.on("SIGTERM", () => { deregister(); process.exit(0); }); process.on("SIGINT", () => { deregister(); process.exit(0); });+}++async function findInstance() {+  if (flags.port) return { port: Number(flags.port) };+  for (const r of readRegistry()) if (alive(r.pid) && await probe(r.port)) return r;+  return null;+}+async function main() {+  if (verb === "serve") { if (!aiThread) die(`caller_identity_required: serve needs the ai-thread name.\n  Re-run: ${APP} --ai-thread "<name>" serve ...`); return serve(); }+  if (verb === "ls") {+    const rows = [];+    for (const r of readRegistry()) rows.push({ ...r, alive: alive(r.pid) && !!(await probe(r.port)), drift: r.version !== VERSION ? `running ${r.version}, installed ${VERSION}` : null });+    console.log(JSON.stringify({ ok: true, instances: rows }, null, 2)); return;+  }+  if (verb === "state") { const i = await findInstance(); if (!i) die("no running instance (start one: serve)"); const s = await new Promise((r) => http.get(`http://127.0.0.1:${i.port}/api/status`, (res) => { let b = ""; res.on("data", d => b += d); res.on("end", () => r(b)); })); console.log(s); return; }+  if (verb === "stop") {+    if (!aiThread) die(`caller_identity_required: stop needs the ai-thread name.`);+    const i = await findInstance(); if (!i) die("no running instance");+    const req = http.request({ host: "127.0.0.1", port: i.port, path: "/shutdown", method: "POST", headers: { "Content-Type": "application/json" } }, (res) => { let b = ""; res.on("data", d => b += d); res.on("end", () => console.log(b)); });+    req.end(JSON.stringify({ aiThread })); return;+  }+  if (verb === "show") {+    if (!aiThread) die(`caller_identity_required: show needs the ai-thread name.\n  Re-run: ${APP} --ai-thread "<name>" show --url <camera stream>`);+    const src = flags.url; if (!src || !checkSourceUrl(src).ok) die("show needs --url <http(s) camera stream>");+    let inst = await findInstance();+    if (!inst) {+      // start our own server detached and wait for it+      const child = (await import("node:child_process")).spawn(process.execPath, [fileURLToPath(import.meta.url), "--ai-thread", aiThread, "serve", "--port", "auto", "--print-url"], { detached: true, stdio: ["ignore", "pipe", "inherit"] });+      const line = await new Promise((r) => { let b = ""; child.stdout.on("data", d => { b += d; if (b.includes("\n")) r(b.trim()); }); setTimeout(() => r(b.trim()), 8000); });+      child.unref();+      const m = /127\.0\.0\.1:(\d+)/.exec(line); if (!m) die("could not start the viewer: " + line);+      inst = { port: Number(m[1]) };+    }+    const q = `?url=${encodeURIComponent(src)}${flags.fps ? `&fps=${encodeURIComponent(flags.fps)}` : ""}${flags.bare ? "&bare=1" : ""}`;+    const proxyBase = (process.env.VSCODE_PROXY_URI || "").replace("{{port}}", String(inst.port));+    const pageUrl = proxyBase ? proxyBase.replace(/\/?$/, "/") + q : `http://127.0.0.1:${inst.port}/${q}`;+    const name = flags.name || "MJPEG Stream Viewer";+    execFile("adom-cli", ["hydrogen", "webview", "open-or-refresh", "--name", name, "--url", pageUrl, "--display-icon", "mdi:cctv"], (err, out) => {+      console.log(JSON.stringify({ ok: !err, port: inst.port, url: pageUrl, tab: name, hint: err ? `open this URL in a Hydrogen webview tab (adom-workspace-control skill): ${pageUrl}` : "opened in a Hydrogen webview tab in a work pane (never the VS Code pane)" }, null, 2));+    });+    return;+  }+  die(`unknown verb '${verb}'. Verbs: serve, show, ls, state, stop. Global flag: --ai-thread "<name>" first.`, 2);+}+main().catch((e) => die(String(e && e.message || e)));
uninstall.shadded+3
@@ -0,0 +1,3 @@+#!/bin/sh+rm -f "$HOME/.local/bin/mjpeg-stream-viewer"+echo "uninstalled: mjpeg-stream-viewer"