| <!doctype html> |
| <html lang="ru"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <title>Death of the Author</title> |
| <style> |
| html, body { width:100%; height:100%; margin:0; background:#000; overflow:hidden; } |
| canvas { width:100vw; height:100vh; display:block; } |
| #hint { |
| position: fixed; |
| left: 16px; bottom: 12px; |
| font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial; |
| color: rgba(255,255,255,0.45); |
| user-select: none; |
| pointer-events: none; |
| } |
| </style> |
| </head> |
| <body> |
| <canvas id="c"></canvas> |
| <div id="hint"></div> |
|
|
| <script> |
| const API_NEXT = "/next"; |
| const FADE_MS = 1600; |
| const TRANSITION = "dissolve"; |
| |
| |
| |
| const DRIFT_MS = 20000; |
| |
| |
| |
| const ZOOM_FROM = 1.14; |
| const ZOOM_TO = 1.00; |
| |
| const MASK_SIZE = 256; |
| const DPR_MAX = 2; |
| |
| const canvas = document.getElementById("c"); |
| const ctx = canvas.getContext("2d", { alpha: false }); |
| |
| const buf = document.createElement("canvas"); |
| const bctx = buf.getContext("2d", { alpha: true }); |
| |
| function resize() { |
| const dpr = Math.min(window.devicePixelRatio || 1, DPR_MAX); |
| canvas.width = Math.floor(innerWidth * dpr); |
| canvas.height = Math.floor(innerHeight * dpr); |
| |
| ctx.imageSmoothingEnabled = true; |
| ctx.imageSmoothingQuality = "high"; |
| |
| buf.width = canvas.width; |
| buf.height = canvas.height; |
| } |
| addEventListener("resize", resize); |
| resize(); |
| |
| const maskCanvas = document.createElement("canvas"); |
| maskCanvas.width = MASK_SIZE; |
| maskCanvas.height = MASK_SIZE; |
| const mctx = maskCanvas.getContext("2d", { willReadFrequently: true }); |
| |
| const maskData = mctx.createImageData(MASK_SIZE, MASK_SIZE); |
| const noise = new Uint8Array(MASK_SIZE * MASK_SIZE); |
| (crypto || window.crypto).getRandomValues(noise); |
| |
| function regenNoise() { |
| (crypto || window.crypto).getRandomValues(noise); |
| } |
| |
| function updateMask(progress01) { |
| const t = Math.max(0, Math.min(255, Math.floor(progress01 * 255))); |
| const d = maskData.data; |
| for (let i = 0, p = 0; i < noise.length; i++, p += 4) { |
| const a = (noise[i] < t) ? 255 : 0; |
| d[p] = 255; d[p+1] = 255; d[p+2] = 255; d[p+3] = a; |
| } |
| mctx.putImageData(maskData, 0, 0); |
| } |
| |
| function blobToImage(blob) { |
| return new Promise((resolve, reject) => { |
| const url = URL.createObjectURL(blob); |
| const img = new Image(); |
| img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; |
| img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); }; |
| img.src = url; |
| }); |
| } |
| |
| async function fetchNext() { |
| const r = await fetch(API_NEXT, { cache: "no-store" }); |
| if (!r.ok) throw new Error("fetch /next failed: " + r.status); |
| const id = Number(r.headers.get("x-frame-id") || "0"); |
| const blob = await r.blob(); |
| const img = await blobToImage(blob); |
| return { id, img }; |
| } |
| |
| let current = null; |
| let incoming = null; |
| let swapping = false; |
| |
| let fadeStart = 0; |
| |
| |
| let driftStartCurrent = performance.now(); |
| let driftStartIncoming = performance.now(); |
| |
| function clamp01(x){ return Math.max(0, Math.min(1, x)); } |
| function easeInOut(x){ |
| x = clamp01(x); |
| return x * x * (3 - 2 * x); |
| } |
| |
| function coverDraw(targetCtx, img, alpha, zoom) { |
| const cw = targetCtx.canvas.width; |
| const ch = targetCtx.canvas.height; |
| const iw = img.naturalWidth || img.width; |
| const ih = img.naturalHeight || img.height; |
| |
| const s = Math.max(cw / iw, ch / ih) * zoom; |
| const w = iw * s, h = ih * s; |
| const x = (cw - w) * 0.5; |
| const y = (ch - h) * 0.5; |
| |
| targetCtx.globalAlpha = alpha; |
| targetCtx.drawImage(img, x, y, w, h); |
| targetCtx.globalAlpha = 1; |
| } |
| |
| function drawLoading(now) { |
| ctx.fillStyle = "#000"; |
| ctx.fillRect(0, 0, canvas.width, canvas.height); |
| |
| const t = (Math.sin(now / 450) * 0.5 + 0.5); |
| ctx.fillStyle = `rgba(255,255,255,${0.22 + 0.22 * t})`; |
| ctx.font = `${Math.floor(canvas.width / 40)}px system-ui, -apple-system, Segoe UI, Roboto, Arial`; |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| ctx.fillText("Генерация…", canvas.width/2, canvas.height/2); |
| } |
| |
| function zoomFor(now, driftStart) { |
| const k = easeInOut(clamp01((now - driftStart) / DRIFT_MS)); |
| return ZOOM_FROM + (ZOOM_TO - ZOOM_FROM) * k; |
| } |
| |
| function tick(now) { |
| if (!current) { |
| drawLoading(now); |
| requestAnimationFrame(tick); |
| return; |
| } |
| |
| ctx.fillStyle = "#000"; |
| ctx.fillRect(0, 0, canvas.width, canvas.height); |
| |
| const zoomCurrent = zoomFor(now, driftStartCurrent); |
| coverDraw(ctx, current.img, 1, zoomCurrent); |
| |
| if (swapping && incoming) { |
| const t = clamp01((now - fadeStart) / FADE_MS); |
| const e = easeInOut(t); |
| |
| |
| const zoomIncoming = zoomFor(now, driftStartIncoming); |
| |
| if (TRANSITION === "fade") { |
| coverDraw(ctx, incoming.img, e, zoomIncoming); |
| } else { |
| updateMask(e); |
| bctx.clearRect(0, 0, buf.width, buf.height); |
| |
| bctx.globalCompositeOperation = "source-over"; |
| bctx.drawImage(maskCanvas, 0, 0, buf.width, buf.height); |
| |
| bctx.globalCompositeOperation = "source-in"; |
| coverDraw(bctx, incoming.img, 1, zoomIncoming); |
| |
| ctx.drawImage(buf, 0, 0); |
| } |
| |
| if (t >= 1) { |
| |
| current = incoming; |
| incoming = null; |
| swapping = false; |
| |
| driftStartCurrent = driftStartIncoming; |
| regenNoise(); |
| } |
| } |
| |
| requestAnimationFrame(tick); |
| } |
| |
| async function run() { |
| while (true) { |
| try { |
| const fr = await fetchNext(); |
| |
| if (!current) { |
| current = fr; |
| driftStartCurrent = performance.now(); |
| requestAnimationFrame(tick); |
| continue; |
| } |
| |
| incoming = fr; |
| |
| if (!swapping) { |
| swapping = true; |
| const now = performance.now(); |
| fadeStart = now; |
| driftStartIncoming = now; |
| } |
| |
| } catch (e) { |
| console.error(e); |
| await new Promise(r => setTimeout(r, 1000)); |
| } |
| } |
| } |
| |
| document.addEventListener("click", () => { |
| if (!document.fullscreenElement) { |
| document.documentElement.requestFullscreen?.().catch(() => {}); |
| } |
| }); |
| |
| run(); |
| </script> |
| </body> |
| </html> |