Spaces:
Running
Running
Download index.html from yusuke-ai/ugoku-code: direct link, hf CLI and curl.
- Browser
- Download file 18.4 kB
-
https://huggingface.co/spaces/yusuke-ai/ugoku-code/resolve/main/index.html
- Command line
-
hf download hf://spaces/yusuke-ai/ugoku-code/index.html
-
curl -L -o index.html https://huggingface.co/spaces/yusuke-ai/ugoku-code/resolve/main/index.html
18.4 kB
| <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 ; } | |
| 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> | |
| ; | |
| 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> | |