ugoku-code / index.html
yusuke-ai's picture
Point the page at the Kyoto clip
57e5158 verified
Raw History Blame Contribute Delete
18.4 kB
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>うごくコード</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DotGothic16&family=Zen+Kaku+Gothic+New:wght@400;700&family=JetBrains+Mono:wght@400;600&display=swap">
<style>
/* Layout: the code screen is the hero on the left, the phone QR rides beside it; the reader is one phone-height column. */
:root {
--bg: #f3f4f7;
--panel: #ffffff;
--ink: #15171f;
--muted: #5d6273;
--line: #d9dce5;
--accent: #2b50d8;
--signal: #f2b51c;
--ok: #1d8a52;
--display: "DotGothic16", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif;
--body: "Zen Kaku Gothic New", "Hiragino Kaku Gothic ProN", "Noto Sans JP", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1117; --panel: #181b24; --ink: #eceef4; --muted: #9aa0b3; --line: #2a2f3d;
--accent: #7d97ff; --signal: #f5c447; --ok: #4fd08b; color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1117; --panel: #181b24; --ink: #eceef4; --muted: #9aa0b3; --line: #2a2f3d;
--accent: #7d97ff; --signal: #f5c447; --ok: #4fd08b; color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg); color: var(--ink); font: 15px/1.7 var(--body);
padding: max(20px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}
[hidden] { display: none !important; }
h1 { font: 400 clamp(28px, 4vw, 40px)/1.2 var(--display); margin: 0; letter-spacing: 0.04em; text-wrap: balance; }
h2 { font: 700 15px/1.4 var(--body); margin: 0; }
p { margin: 0; }
.muted { color: var(--muted); }
.eyebrow { font: 600 11px/1 var(--mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
button, .chip {
font: 700 14px/1 var(--body); color: var(--ink); background: var(--panel);
border: 1px solid var(--line); border-radius: 999px; padding: 10px 16px; cursor: pointer;
}
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
/* ---- display (PC) ---- */
.wrap { max-width: 1180px; margin: 0 auto; display: grid; gap: 20px; }
.head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; }
.stage { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 20px; align-items: start; }
@media (max-width: 860px) { .stage { grid-template-columns: 1fr; } }
.screen { background: #000; border-radius: 6px; padding: 18px; display: grid; place-items: center; position: relative; }
/* the source video must be in the page and on screen, or browsers pause it; the canvas covers it */
#clipSrc { position: absolute; width: 40px; height: 40px; left: 18px; top: 18px; opacity: 0.01; pointer-events: none; }
#code { position: relative; }
#code { width: min(100%, 78vh); aspect-ratio: 1; max-width: 100%; display: block; image-rendering: auto; }
.side { display: grid; gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; min-width: 0; }
#qr { background: #fff; padding: 12px; border-radius: 4px; justify-self: center; line-height: 0; }
#qr img, #qr canvas { width: 196px; height: 196px; }
.url { font: 12px/1.5 var(--mono); color: var(--muted); word-break: break-all; }
textarea { width: 100%; min-height: 84px; resize: vertical; font: 14px/1.5 var(--body); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 10px; }
.stats { font: 12px/1.6 var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; }
.stats a { color: var(--accent); }
ol.steps { margin: 0; padding-left: 1.3em; display: grid; gap: 4px; }
/* ---- reader (phone) ---- */
.reader { max-width: 560px; margin: 0 auto; display: grid; gap: 14px; }
.cam { position: relative; background: #000; border-radius: 6px; overflow: hidden; aspect-ratio: 3 / 4; max-width: 100%; }
.cam video, .cam canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.cam .start { position: absolute; inset: 0; display: grid; place-items: center; gap: 8px; color: #fff; text-align: center; padding: 24px; }
#status { font: 700 16px/1.5 var(--body); min-height: 1.5em; }
.mosaic { display: flex; gap: 4px; flex-wrap: wrap; min-height: 14px; }
.mosaic i { width: 18px; height: 14px; border-radius: 2px; background: var(--line); transition: background 0.2s; }
.mosaic i.on { background: var(--signal); }
.result { border: 2px solid var(--ok); }
.result .msg { font: 700 18px/1.6 var(--body); word-break: break-word; }
.result a { color: var(--accent); }
@media (prefers-reduced-motion: reduce) { .mosaic i { transition: none; } }
</style>
</head>
<body>
<main class="wrap" id="display">
<div class="head">
<div>
<div class="eyebrow">Motion code · 光ハッカソン</div>
<h1>うごくコード</h1>
</div>
<p class="muted">絵や動画そのものが、ちらつきでデータを運びます。四隅のマークも白黒の模様もいりません。</p>
</div>
<div class="stage">
<div class="screen"><video id="clipSrc" muted loop playsinline autoplay preload="auto" aria-hidden="true"></video><canvas id="code" width="720" height="720" aria-label="データを運ぶコード画面"></canvas></div>
<div class="side">
<div class="card">
<h2>スマホで読む</h2>
<div id="qr" aria-label="読み取りページのQRコード"></div>
<ol class="steps muted">
<li>QRをスマホのカメラで開く</li>
<li>「カメラを起動」を押す</li>
<li>この画面にカメラを向けて数秒待つ</li>
</ol>
<div class="url" id="readerUrl"></div>
</div>
<div class="card">
<h2>土台</h2>
<div class="chips" role="group" aria-label="土台">
<button class="chip" id="modeVideo" aria-pressed="true">実写動画(京都)</button>
<button class="chip" id="modeStill" aria-pressed="false">静止画</button>
</div>
<button id="fs">全画面で表示</button>
<p class="stats">動画: <a href="https://www.pexels.com/video/traditional-kyoto-street-with-wooden-houses-32111766/" target="_blank" rel="noopener">Liuuu _61 / Pexels</a></p>
</div>
<div class="card">
<h2>埋め込む内容</h2>
<textarea id="msg" aria-label="埋め込む内容"></textarea>
<button class="primary" id="apply">この内容で作り直す</button>
<div class="stats" id="stats"></div>
</div>
</div>
</div>
</main>
<main class="reader" id="reader" hidden>
<div>
<div class="eyebrow">うごくコード · 読み取り</div>
<h1>画面に向けて</h1>
</div>
<div class="chips" role="group" aria-label="読み取る土台">
<button class="chip" id="rVideo" aria-pressed="true">実写動画</button>
<button class="chip" id="rStill" aria-pressed="false">静止画</button>
</div>
<div class="cam">
<video id="video" playsinline muted></video>
<canvas id="overlay"></canvas>
<div class="start" id="startBox">
<button class="primary" id="start">カメラを起動</button>
<p>PCのコード画面がまるごと映る距離(30cm〜1m)で、なるべく動かさずに。</p>
</div>
</div>
<p id="status" role="status">カメラを起動してください</p>
<div class="mosaic" id="mosaic" aria-label="受信した断片"></div>
<div class="card result" id="result" hidden>
<div class="eyebrow">読めた内容</div>
<div class="msg" id="resultMsg"></div>
<div class="stats" id="resultStats"></div>
</div>
<div class="chips">
<button id="restart">もう一度読む</button>
</div>
<p class="stats" id="perf"></p>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="ugokode.js"></script>
<script>
'use strict';
const params = new URLSearchParams(location.search);
const DEFAULT_MSG = 'https://example.com/hikari?id=42 / 動くコードで読めた! 光の2時間ハッカソン';
// ================= display (PC) =================
function startDisplay() {
const W = 720;
const cv = document.getElementById('code');
const ctx = cv.getContext('2d', { willReadFrequently: true });
const src = document.createElement('canvas');
src.width = src.height = W;
const sctx = src.getContext('2d', { willReadFrequently: true });
const out = ctx.createImageData(W, W);
const art = new Image();
const vid = document.getElementById('clipSrc');
// HF serves large files from another origin; going through blob: URLs keeps
// the canvas readable (a cross-origin draw would block getImageData)
const blobUrl = (u) => fetch(u).then((r) => r.blob()).then((b) => URL.createObjectURL(b));
blobUrl('art.jpg').then((u) => { art.src = u; });
blobUrl('kyoto.mp4').then((u) => { vid.src = u; if (mode === 'video') vid.play().catch(() => {}); });
let mode = 'video', enc = null, base = null, t0 = performance.now();
const msgEl = document.getElementById('msg');
msgEl.value = DEFAULT_MSG;
function rebuild() {
const bytes = new TextEncoder().encode(msgEl.value || ' ');
try {
enc = new Ugokode.Encoder(W, W, bytes);
} catch (e) {
document.getElementById('stats').textContent = '長すぎます。4000バイト以内にしてください。';
return;
}
const loop = (enc.length / 60).toFixed(2);
document.getElementById('stats').textContent =
`${bytes.length} バイト · 断片 ${enc.packets.length} 個 · 1周 ${loop} 秒`;
t0 = performance.now();
}
function setMode(m) {
mode = m;
document.getElementById('modeStill').setAttribute('aria-pressed', m === 'still');
document.getElementById('modeVideo').setAttribute('aria-pressed', m === 'video');
if (m === 'video') vid.play().catch(() => {}); else vid.pause();
// autoplay may need a gesture: any click on the page starts the clip
base = null;
const url = location.origin + location.pathname + '?read=1&mode=' + m;
document.getElementById('readerUrl').textContent = url;
const qr = document.getElementById('qr');
qr.textContent = '';
new QRCode(qr, { text: url, width: 196, height: 196, correctLevel: QRCode.CorrectLevel.M });
}
document.addEventListener('click', () => { if (mode === 'video' && vid.paused) vid.play().catch(() => {}); });
document.getElementById('modeStill').onclick = () => setMode('still');
document.getElementById('modeVideo').onclick = () => setMode('video');
document.getElementById('apply').onclick = rebuild;
document.getElementById('fs').onclick = () => {
const el = document.querySelector('.screen');
(el.requestFullscreen ? el.requestFullscreen() : Promise.reject()).catch(() => {});
};
function tick(now) {
if (enc) {
if (mode === 'still' && !base && art.complete && art.naturalWidth) {
sctx.drawImage(art, 0, 0, W, W);
base = sctx.getImageData(0, 0, W, W).data;
}
if (mode === 'video' && vid.readyState >= 2) {
sctx.drawImage(vid, 0, 0, W, W);
base = sctx.getImageData(0, 0, W, W).data;
}
if (base) {
// the code clock runs at 60 display frames per second whatever the monitor rate
enc.frame(Math.floor(((now - t0) * 60) / 1000), base, out.data);
ctx.putImageData(out, 0, 0);
}
}
requestAnimationFrame(tick);
}
rebuild();
setMode('video');
requestAnimationFrame(tick);
}
// ================= reader (phone) =================
function startReader() {
document.getElementById('display').hidden = true;
document.getElementById('reader').hidden = false;
let mode = params.get('mode') === 'still' ? 'still' : 'video';
const video = document.getElementById('video');
const overlay = document.getElementById('overlay');
const octx = overlay.getContext('2d');
const work = document.createElement('canvas');
const wctx = work.getContext('2d', { willReadFrequently: true });
const statusEl = document.getElementById('status');
const mosaic = document.getElementById('mosaic');
let dec = null, running = false, t0 = 0, frames = 0, procMs = 0, fpsEst = 30, gray = null, done = false;
function setMode(m) {
mode = m;
document.getElementById('rStill').setAttribute('aria-pressed', m === 'still');
document.getElementById('rVideo').setAttribute('aria-pressed', m === 'video');
reset();
}
function reset() {
dec = new Ugokode.Decoder({ fps: fpsEst, moving: mode === 'video' });
t0 = performance.now();
frames = 0;
done = false;
document.getElementById('result').hidden = true;
mosaic.textContent = '';
if (running) statusEl.textContent = '画面のコードを探しています…';
}
document.getElementById('rStill').onclick = () => setMode('still');
document.getElementById('rVideo').onclick = () => setMode('video');
document.getElementById('restart').onclick = reset;
document.getElementById('start').onclick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
});
video.srcObject = stream;
await video.play();
document.getElementById('startBox').hidden = true;
running = true;
reset();
schedule();
} catch (e) {
statusEl.textContent = 'カメラを使えませんでした。ブラウザの設定でカメラを許可してから、もう一度押してください。';
}
};
function schedule() {
if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(process);
else requestAnimationFrame(process);
}
function renderMosaic() {
if (!dec.total) return;
if (mosaic.children.length !== dec.total) {
mosaic.textContent = '';
for (let i = 0; i < dec.total; i++) mosaic.appendChild(document.createElement('i'));
}
[...mosaic.children].forEach((el, i) => el.classList.toggle('on', dec.packets.has(i)));
}
function showResult() {
const text = new TextDecoder().decode(dec.message);
const box = document.getElementById('resultMsg');
box.textContent = '';
// make URLs tappable, keep everything else as plain text
text.split(/(https?:\/\/\S+)/).forEach((part) => {
if (/^https?:\/\//.test(part)) {
const a = document.createElement('a');
a.href = part; a.textContent = part; a.target = '_blank'; a.rel = 'noopener';
box.appendChild(a);
} else box.appendChild(document.createTextNode(part));
});
const sec = ((performance.now() - t0) / 1000).toFixed(1);
document.getElementById('resultStats').textContent = `${sec} 秒で読み取り · ${dec.message.length} バイト · 断片 ${dec.total} 個`;
document.getElementById('result').hidden = false;
statusEl.textContent = '読めました';
if (navigator.vibrate) navigator.vibrate(60);
}
function process() {
if (!running) return;
const vw = video.videoWidth, vh = video.videoHeight;
if (vw && !done) {
const sc = Math.min(1, 640 / Math.max(vw, vh));
const w = Math.round(vw * sc), h = Math.round(vh * sc);
if (work.width !== w || work.height !== h) {
work.width = w; work.height = h;
gray = new Float32Array(w * h);
}
const t = performance.now();
wctx.drawImage(video, 0, 0, w, h);
const px = wctx.getImageData(0, 0, w, h).data;
for (let i = 0, j = 0; i < gray.length; i++, j += 4) gray[i] = 0.299 * px[j] + 0.587 * px[j + 1] + 0.114 * px[j + 2];
dec.push(gray, w, h);
procMs = 0.9 * procMs + 0.1 * (performance.now() - t);
frames++;
// the moving-video filter needs the real frame rate we manage to process
if (frames === 20) {
const f = (frames * 1000) / (performance.now() - t0);
if (mode === 'video' && Math.abs(Math.round((f * 4) / 60) - dec.lag) >= 1) {
fpsEst = f;
reset();
}
}
drawOverlay(w, h);
renderMosaic();
if (dec.message) {
done = true;
showResult();
} else if (dec.total) statusEl.textContent = `受信中 ${dec.packets.size} / ${dec.total}`;
else if (dec.quad) statusEl.textContent = 'コードを見つけました。そのまま向けてください';
const el = (performance.now() - t0) / 1000;
document.getElementById('perf').textContent =
`処理 ${procMs.toFixed(0)} ms/枚 · ${(frames / Math.max(el, 0.001)).toFixed(0)} 枚/秒 · ${w}×${h}`;
}
schedule();
}
function drawOverlay(w, h) {
const box = overlay.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
if (overlay.width !== Math.round(box.width * dpr)) {
overlay.width = Math.round(box.width * dpr);
overlay.height = Math.round(box.height * dpr);
}
octx.clearRect(0, 0, overlay.width, overlay.height);
const q = dec.currentQuad;
if (!q) return;
// object-fit: cover mapping from processed frame to the overlay
const s = Math.max(overlay.width / w, overlay.height / h);
const ox = (overlay.width - w * s) / 2, oy = (overlay.height - h * s) / 2;
octx.lineWidth = 3 * dpr;
octx.strokeStyle = done ? '#4fd08b' : '#f5c447';
octx.beginPath();
q.forEach(([x, y], i) => (i ? octx.lineTo(ox + x * s, oy + y * s) : octx.moveTo(ox + x * s, oy + y * s)));
octx.closePath();
octx.stroke();
}
setMode(mode);
}
if (params.get('read')) startReader(); else startDisplay();
</script>
</body>
</html>